diff --git a/packages/@uppy/transloadit/src/index.test.js b/packages/@uppy/transloadit/src/index.test.js index 3dbfa5563..8f3cf6e7d 100644 --- a/packages/@uppy/transloadit/src/index.test.js +++ b/packages/@uppy/transloadit/src/index.test.js @@ -77,37 +77,99 @@ describe('Transloadit', () => { }) it('should complete when resuming after pause', async () => { - let firstUploadCallCount = 0 + const assemblyStatusBase = { + assembly_id: 'test-assembly-id', + websocket_url: 'ws://localhost:8080', + tus_url: 'http://localhost/resumable/files/', + assembly_ssl_url: + 'https://api2.transloadit.com/assemblies/test-assembly-id', + } + + const tusUploads = new Map() + let uploadIndex = 0 + const tusBaseUrl = 'http://localhost/resumable/files/' const server = setupServer( - http.post('*/assemblies', ({ request }) => { + http.options('http://localhost/resumable/files*', () => { + return new HttpResponse(null, { + status: 204, + headers: { + 'Tus-Resumable': '1.0.0', + 'Tus-Version': '1.0.0', + 'Tus-Extension': 'creation,creation-defer-length', + }, + }) + }), + http.post('http://localhost/resumable/files*', ({ request }) => { + const uploadLengthHeader = request.headers.get('upload-length') + const uploadLength = uploadLengthHeader ? Number(uploadLengthHeader) : 0 + const uploadId = `test-upload-${uploadIndex++}` + tusUploads.set(uploadId, { + length: Number.isNaN(uploadLength) ? 0 : uploadLength, + offset: 0, + }) + + return new HttpResponse(null, { + status: 201, + headers: { + Location: `${tusBaseUrl}${uploadId}`, + 'Upload-Offset': '0', + 'Tus-Resumable': '1.0.0', + }, + }) + }), + http.head('http://localhost/resumable/files/:uploadId', ({ params }) => { + const upload = tusUploads.get(params.uploadId) + if (!upload) { + return new HttpResponse(null, { status: 404 }) + } + return new HttpResponse(null, { + status: 200, + headers: { + 'Upload-Offset': String(upload.offset), + 'Upload-Length': String(upload.length), + 'Tus-Resumable': '1.0.0', + }, + }) + }), + http.patch( + 'http://localhost/resumable/files/:uploadId', + async ({ request, params }) => { + const upload = tusUploads.get(params.uploadId) + if (!upload) { + return new HttpResponse(null, { status: 404 }) + } + if (upload.offset === 0) { + await new Promise((resolve) => setTimeout(resolve, 200)) + } + const body = await request.arrayBuffer() + const offsetHeader = request.headers.get('upload-offset') + const baseOffset = offsetHeader ? Number(offsetHeader) : upload.offset + const nextOffset = baseOffset + body.byteLength + upload.offset = nextOffset + + return new HttpResponse(null, { + status: 204, + headers: { + 'Upload-Offset': String(nextOffset), + 'Tus-Resumable': '1.0.0', + }, + }) + }, + ), + http.post('https://api2.transloadit.com/assemblies', ({ request }) => { return HttpResponse.json({ - assembly_id: 'test-assembly-id', - websocket_url: 'ws://localhost:8080', - tus_url: 'https://localhost/resumable/files/', - assembly_ssl_url: 'https://localhost/assemblies/test-assembly-id', + ...assemblyStatusBase, ok: 'ASSEMBLY_EXECUTING', }) }), - http.get('*/assemblies/*', () => { + http.get('https://api2.transloadit.com/assemblies/*', () => { return HttpResponse.json({ - assembly_id: 'test-assembly-id', + ...assemblyStatusBase, ok: 'ASSEMBLY_COMPLETED', results: {}, }) }), - http.post('*/resumable/files/', () => { - firstUploadCallCount++ - return HttpResponse.json({ - tus_enabled: true, - resumable_file_id: `test-file-id-${firstUploadCallCount}`, - }) - }), - http.patch('*/resumable/files/*', () => { - return HttpResponse.json({ - ok: 'RESUMABLE_FILE_UPLOADED', - }) - }), http.post('https://transloaditstatus.com/client_error', () => { return HttpResponse.json({}) }), @@ -130,21 +192,129 @@ describe('Transloadit', () => { uppy.addFile({ source: 'test', name: 'cat.jpg', - data: Buffer.from('test file content'), + data: new File([new Uint8Array([1, 2, 3, 4, 5])], 'cat.jpg', { + type: 'image/jpeg', + }), }) uppy.addFile({ source: 'test', name: 'traffic.jpg', - data: Buffer.from('test file content 2'), + data: new File([new Uint8Array([6, 7, 8, 9, 10, 11])], 'traffic.jpg', { + type: 'image/jpeg', + }), }) - uppy.upload() + // Initially should be true + expect(uppy.getState().allowNewUpload).toBe(true) + + const uploadPromise = uppy.upload() + + // Should be set to false during upload + expect(uppy.getState().allowNewUpload).toBe(false) + + // Trying to add a new file during upload with Transloadit should not be possible + expect(() => + uppy.addFile({ + source: 'test', + name: 'additionalFile.jpg', + data: new File([new Uint8Array([0])], 'additionalFile.jpg', { + type: 'image/jpeg', + }), + }), + ).toThrowError('Cannot add more files') + await new Promise((resolve) => setTimeout(resolve, 100)) uppy.pauseAll() - await uppy.upload() + uppy.resumeAll() + + await uploadPromise expect(successSpy).toHaveBeenCalled() + // Should be reset to true after upload completes + expect(uppy.getState().allowNewUpload).toBe(true) + server.close() }) + + it('resets allowNewUpload to true on preprocessor error', async () => { + const uppy = new Core() + uppy.use(Transloadit, { + assemblyOptions: { + params: { + auth: { key: 'test-auth-key' }, + template_id: 'test-template-id', + }, + }, + }) + + // Mock createAssembly to throw an error + uppy.getPlugin('Transloadit').client.createAssembly = () => + Promise.reject(new Error('Assembly creation failed')) + + uppy.addFile({ + source: 'test', + name: 'test.jpg', + data: Buffer.from('test file content'), + }) + + // Initially should be true + expect(uppy.getState().allowNewUpload).toBe(true) + + try { + await uppy.upload() + } catch { + // Expected to fail + } + + // Should be reset to true after error + expect(uppy.getState().allowNewUpload).toBe(true) + }) + + it('resets allowNewUpload to true on cancel-all', async () => { + const uppy = new Core() + uppy.use(Transloadit, { + assemblyOptions: { + params: { + auth: { key: 'test-auth-key' }, + template_id: 'test-template-id', + }, + }, + }) + + // Manually set allowNewUpload to false to simulate an upload in progress + uppy.setState({ allowNewUpload: false }) + expect(uppy.getState().allowNewUpload).toBe(false) + + // Simulate cancel-all + uppy.cancelAll() + + // Should be reset to true + expect(uppy.getState().allowNewUpload).toBe(true) + }) + + it('resets allowNewUpload to true on error event', () => { + const uppy = new Core() + uppy.use(Transloadit, { + assemblyOptions: { + params: { + auth: { key: 'test-auth-key' }, + template_id: 'test-template-id', + }, + }, + }) + + // Manually set allowNewUpload to false to simulate an upload in progress + uppy.setState({ allowNewUpload: false }) + expect(uppy.getState().allowNewUpload).toBe(false) + + // Trigger error event + uppy.emit('error', { + name: 'TestError', + message: 'Test error message', + }) + + // Should be reset to true + expect(uppy.getState().allowNewUpload).toBe(true) + }) }) diff --git a/packages/@uppy/transloadit/src/index.ts b/packages/@uppy/transloadit/src/index.ts index aa83d414e..305c0aa89 100644 --- a/packages/@uppy/transloadit/src/index.ts +++ b/packages/@uppy/transloadit/src/index.ts @@ -639,12 +639,15 @@ export default class Transloadit< * When all files are removed, cancel in-progress Assemblies. */ #onCancelAll = async () => { - if (!this.assembly) return - try { - await this.#cancelAssembly(this.assembly.status) - } catch (err) { - this.uppy.log(err) + if (this.assembly) { + try { + await this.#cancelAssembly(this.assembly.status) + } catch (err) { + this.uppy.log(err) + } } + // Reset allowNewUpload when upload is cancelled + this.uppy.setState({ allowNewUpload: true }) } #onRestored = (pluginData: Record) => { @@ -813,6 +816,10 @@ export default class Transloadit< } #prepareUpload = async (fileIDs: string[]) => { + // Prevent adding/dropping files during upload to avoid creating multiple assemblies + // TODO we should rewrite to instead infer allowNewUpload based on upload state + this.uppy.setState({ allowNewUpload: false }) + const assemblyOptions = ( typeof this.opts.assemblyOptions === 'function' ? await this.opts.assemblyOptions() @@ -850,6 +857,8 @@ export default class Transloadit< this.uppy.emit('preprocess-complete', file) this.uppy.emit('upload-error', file, err) }) + // Reset allowNewUpload on error + this.uppy.setState({ allowNewUpload: true }) throw err } } @@ -916,6 +925,8 @@ export default class Transloadit< // we need to allow a new assembly to be created. // see https://github.com/transloadit/uppy/issues/5397 this.assembly = undefined + // Allow new uploads now that this upload is complete + this.uppy.setState({ allowNewUpload: true }) } } @@ -931,6 +942,9 @@ export default class Transloadit< .submitError(err) // if we can't report the error that sucks .catch(sendErrorToConsole(err)) + + // Reset allowNewUpload when upload encounters an error + this.uppy.setState({ allowNewUpload: true }) } #onTusError = (_: UppyFile | undefined, err: Error) => { @@ -1008,6 +1022,7 @@ export default class Transloadit< this.uppy.removePreProcessor(this.#prepareUpload) this.uppy.removePostProcessor(this.#afterUpload) this.uppy.off('error', this.#onError) + this.uppy.off('cancel-all', this.#onCancelAll) if (this.opts.importFromUploadURLs) { this.uppy.off('upload-success', this.#onFileUploadURLAvailable)