UNPKG

stream-chat-react-native-core

Version:

The official React Native and Expo components for Stream Chat, a service for building chat applications

268 lines (242 loc) 8.16 kB
import { createNativeMultipartUpload, createNativeMultipartUploader, NativeMultipartAbortSignal, NativeMultipartUploadEventEmitter, NativeMultipartUploadNativeResponse, NativeMultipartUploadProgressEvent, NativeMultipartUploaderModule, } from '../nativeMultipartUpload'; const progressEventName = 'streamMultipartUploadProgress'; const filePart = { fieldName: 'file', fileName: 'test.jpg', kind: 'file' as const, mimeType: 'image/jpeg', uri: 'file:///tmp/test.jpg', }; const createNativeModule = () => ({ addListener: jest.fn(), cancelUpload: jest.fn(() => Promise.resolve()), removeListeners: jest.fn(), uploadMultipart: jest.fn< ReturnType<NativeMultipartUploaderModule['uploadMultipart']>, Parameters<NativeMultipartUploaderModule['uploadMultipart']> >(() => Promise.resolve({ body: 'ok', headers: [{ name: 'x-test', value: 'yes' }], status: 201, statusText: 'Created', }), ), }); const createEventEmitter = () => { const listeners = new Map<string, Set<(event: NativeMultipartUploadProgressEvent) => void>>(); const subscriptions: Array<{ remove: jest.Mock }> = []; const eventEmitter: NativeMultipartUploadEventEmitter & { emit: (eventType: string, event: NativeMultipartUploadProgressEvent) => void; subscriptions: Array<{ remove: jest.Mock }>; } = { addListener: jest.fn((eventType, listener) => { const eventListeners = listeners.get(eventType) ?? new Set(); eventListeners.add(listener); listeners.set(eventType, eventListeners); const subscription = { remove: jest.fn(() => { eventListeners.delete(listener); }), }; subscriptions.push(subscription); return subscription; }), emit: (eventType, event) => { listeners.get(eventType)?.forEach((listener) => listener(event)); }, subscriptions, }; return eventEmitter; }; describe('nativeMultipartUpload', () => { it('does not create a native uploader when the native module is missing', () => { expect(createNativeMultipartUploader(null)).toBeUndefined(); }); it('passes requests to the native module and forwards matching progress events', async () => { const nativeModule = createNativeModule(); const eventEmitter = createEventEmitter(); let resolveUpload: (response: NativeMultipartUploadNativeResponse) => void; nativeModule.uploadMultipart.mockImplementation( () => new Promise<NativeMultipartUploadNativeResponse>((resolve) => { resolveUpload = (response) => resolve(response); }), ); const uploadMultipart = createNativeMultipartUploader(nativeModule, { eventEmitter }); const onProgress = jest.fn(); const responsePromise = uploadMultipart?.({ headers: { Authorization: 'token' }, method: 'POST', onProgress, parts: [filePart], progress: { count: 10 }, timeoutMs: 1234, uploadId: 'upload-id', url: 'https://example.com/upload', }); eventEmitter.emit(progressEventName, { loaded: 5, total: 10, uploadId: 'other-upload-id', }); eventEmitter.emit(progressEventName, { loaded: 10, total: null, uploadId: 'upload-id', }); resolveUpload!({ body: 'ok', headers: [{ name: 'x-test', value: 'yes' }], status: 201, statusText: null, }); await expect(responsePromise).resolves.toEqual({ body: 'ok', headers: { 'x-test': 'yes' }, status: 201, statusText: undefined, }); expect(onProgress).toHaveBeenCalledTimes(1); expect(onProgress).toHaveBeenCalledWith({ loaded: 10, total: undefined }); expect(nativeModule.uploadMultipart).toHaveBeenCalledWith( 'upload-id', 'https://example.com/upload', 'POST', [{ name: 'Authorization', value: 'token' }], [filePart], { count: 10 }, 1234, ); expect(eventEmitter.subscriptions[0].remove).toHaveBeenCalledTimes(1); }); it('throws an Axios-compatible cancellation error without pre-canceling native uploads', async () => { const nativeModule = createNativeModule(); const uploadMultipart = createNativeMultipartUploader(nativeModule, { eventEmitter: createEventEmitter(), }); await expect( uploadMultipart?.({ headers: {}, method: 'POST', parts: [filePart], signal: { aborted: true }, uploadId: 'upload-id', url: 'https://example.com/upload', }), ).rejects.toMatchObject({ __CANCEL__: true, code: 'ERR_CANCELED', name: 'CanceledError', }); expect(nativeModule.cancelUpload).not.toHaveBeenCalled(); expect(nativeModule.uploadMultipart).not.toHaveBeenCalled(); }); it('supports onabort-only signals and restores the previous handler', async () => { const nativeModule = createNativeModule(); const eventEmitter = createEventEmitter(); let rejectUpload: (error: Error) => void; nativeModule.uploadMultipart.mockImplementation( () => new Promise((_, reject) => { rejectUpload = reject; }), ); const uploadMultipart = createNativeMultipartUploader(nativeModule, { eventEmitter }); const previousOnAbort = jest.fn(); const signal: NativeMultipartAbortSignal = { aborted: false, onabort: previousOnAbort, }; const responsePromise = uploadMultipart?.({ headers: {}, method: 'POST', parts: [filePart], signal, uploadId: 'upload-id', url: 'https://example.com/upload', }); signal.aborted = true; signal.onabort?.('abort-event'); rejectUpload!(new Error('native aborted')); await expect(responsePromise).rejects.toMatchObject({ __CANCEL__: true, code: 'ERR_CANCELED', name: 'CanceledError', }); expect(previousOnAbort).toHaveBeenCalledWith('abort-event'); expect(nativeModule.cancelUpload).toHaveBeenCalledWith('upload-id'); expect(signal.onabort).toBe(previousOnAbort); }); it('does not create a multipart upload handler without an uploader', () => { expect(createNativeMultipartUpload({ uploadMultipart: undefined })).toBeUndefined(); }); it('resolves photo library URIs and strips non-native progress options', async () => { const uploadMultipart = jest.fn(() => Promise.resolve({ body: 'ok', status: 200, }), ); const getLocalAssetUri = jest.fn(() => Promise.resolve('/tmp/image.jpg?token=1#fragment')); const multipartUpload = createNativeMultipartUpload({ getLocalAssetUri, uploadIdFactory: () => 'generated-upload-id', uploadMultipart, }); await multipartUpload?.({ headers: {}, method: 'POST', parts: [{ ...filePart, uri: 'ph://asset-id' }], progress: { completionProgressCap: 75, count: 10, intervalMs: 50, }, url: 'https://example.com/upload', }); expect(getLocalAssetUri).toHaveBeenCalledWith('ph://asset-id'); expect(uploadMultipart).toHaveBeenCalledWith( expect.objectContaining({ parts: [{ ...filePart, uri: 'file:///tmp/image.jpg' }], progress: { count: 10, intervalMs: 50, }, uploadId: 'generated-upload-id', }), ); }); it('falls back to the original photo library URI when JS resolution fails', async () => { const uploadMultipart = jest.fn(() => Promise.resolve({ body: 'ok', status: 200, }), ); const multipartUpload = createNativeMultipartUpload({ getLocalAssetUri: jest.fn(() => Promise.reject(new Error('resolution failed'))), uploadIdFactory: () => 'generated-upload-id', uploadMultipart, }); await multipartUpload?.({ headers: {}, method: 'POST', parts: [{ ...filePart, uri: 'assets-library://asset-id' }], url: 'https://example.com/upload', }); expect(uploadMultipart).toHaveBeenCalledWith( expect.objectContaining({ parts: [{ ...filePart, uri: 'assets-library://asset-id' }], }), ); }); });