UNPKG

@wordpress/upload-media

Version:
1,736 lines (1,466 loc) 63.5 kB
/** * WordPress dependencies */ import { createRegistry } from '@wordpress/data'; type WPDataRegistry = ReturnType< typeof createRegistry >; /** * Internal dependencies */ import { store as uploadStore } from '..'; import { ItemStatus, OperationType } from '../types'; import { unlock } from '../../lock-unlock'; jest.mock( '@wordpress/blob', () => ( { __esModule: true, createBlobURL: jest.fn( () => 'blob:foo' ), isBlobURL: jest.fn( ( str: string ) => str.startsWith( 'blob:' ) ), revokeBlobURL: jest.fn(), } ) ); jest.mock( '../utils', () => ( { vipsCancelOperations: jest.fn( () => Promise.resolve( true ) ), vipsResizeImage: jest.fn( () => Promise.resolve( new File( [ 'resized' ], 'example-100x100.jpg', { type: 'image/jpeg', } ) ) ), vipsRotateImage: jest.fn(), vipsHasTransparency: jest.fn( () => Promise.resolve( false ) ), vipsConvertImageFormat: jest.fn(), terminateVipsWorker: jest.fn(), } ) ); /* * actions.ts transitively imports private-actions, which also pulls in * convertGifToVideo / isUnsupportedConversionError, so the mock must cover the * whole module surface. isUnsupportedConversionError is kept real. */ jest.mock( '../utils/video-conversion', () => { const actual = jest.requireActual( '../utils/video-conversion' ); return { convertGifToVideo: jest.fn(), cancelGifToVideoOperations: jest.fn( () => Promise.resolve( true ) ), terminateVideoConversionWorker: jest.fn(), isUnsupportedConversionError: actual.isUnsupportedConversionError, }; } ); // Import the mocked modules to access the mock functions. import { vipsCancelOperations } from '../utils'; import { cancelGifToVideoOperations } from '../utils/video-conversion'; function createRegistryWithStores() { // Create a registry and register used stores. const registry = createRegistry(); // @ts-ignore [ uploadStore ].forEach( registry.register ); return registry; } const jpegFile = new File( [ 'foo' ], 'example.jpg', { lastModified: 1234567891, type: 'image/jpeg', } ); const mp4File = new File( [ 'foo' ], 'amazing-video.mp4', { lastModified: 1234567891, type: 'video/mp4', } ); describe( 'actions', () => { let registry: WPDataRegistry; beforeEach( () => { registry = createRegistryWithStores(); unlock( registry.dispatch( uploadStore ) ).pauseQueue(); } ); describe( 'addItem', () => { it( 'adds an item to the queue', () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); expect( registry.select( uploadStore ).getItems() ).toHaveLength( 1 ); expect( registry.select( uploadStore ).getItems()[ 0 ] ).toStrictEqual( expect.objectContaining( { id: expect.any( String ), file: jpegFile, sourceFile: jpegFile, status: ItemStatus.Processing, attachment: { url: expect.stringMatching( /^blob:/ ), }, } ) ); } ); } ); describe( 'addItems', () => { it( 'adds multiple items to the queue', () => { const onError = jest.fn(); registry.dispatch( uploadStore ).addItems( { files: [ jpegFile, mp4File ], onError, } ); expect( onError ).not.toHaveBeenCalled(); expect( registry.select( uploadStore ).getItems() ).toHaveLength( 2 ); expect( registry.select( uploadStore ).getItems()[ 0 ] ).toStrictEqual( expect.objectContaining( { id: expect.any( String ), file: jpegFile, sourceFile: jpegFile, status: ItemStatus.Processing, attachment: { url: expect.stringMatching( /^blob:/ ), }, } ) ); expect( registry.select( uploadStore ).getItems()[ 1 ] ).toStrictEqual( expect.objectContaining( { id: expect.any( String ), file: mp4File, sourceFile: mp4File, status: ItemStatus.Processing, attachment: { url: expect.stringMatching( /^blob:/ ), }, } ) ); } ); } ); describe( 'addSideloadItem', () => { it( 'adds a sideload item with parent ID', () => { // Add parent item first. unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const parentItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; unlock( registry.dispatch( uploadStore ) ).addSideloadItem( { file: jpegFile, parentId: parentItem.id, additionalData: { post: 123, image_size: 'thumbnail' }, } ); const items = unlock( registry.select( uploadStore ) ).getAllItems(); expect( items ).toHaveLength( 2 ); expect( items[ 1 ].parentId ).toBe( parentItem.id ); expect( items[ 1 ].additionalData ).toEqual( expect.objectContaining( { post: 123, image_size: 'thumbnail', } ) ); } ); it( 'adds a sideload item with custom operations', () => { unlock( registry.dispatch( uploadStore ) ).addSideloadItem( { file: jpegFile, additionalData: { post: 456, image_size: 'medium' }, } ); const items = unlock( registry.select( uploadStore ) ).getAllItems(); expect( items ).toHaveLength( 1 ); expect( items[ 0 ].status ).toBe( ItemStatus.Processing ); } ); } ); describe( 'prepareItem', () => { it( 'should add Upload and ThumbnailGeneration for vips-supported image types', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Manually call prepareItem to determine operations. await unlock( registry.dispatch( uploadStore ) ).prepareItem( item.id ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Should include Upload and ThumbnailGeneration (no ResizeCrop without bigImageSizeThreshold). expect( updatedItem.operations ).toEqual( expect.arrayContaining( [ OperationType.Upload, OperationType.ThumbnailGeneration, ] ) ); // Server should not generate sub-sizes for vips-supported images. expect( updatedItem.additionalData.generate_sub_sizes ).toBe( false ); } ); it( 'should add only Upload for non-image types', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: mp4File, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; await unlock( registry.dispatch( uploadStore ) ).prepareItem( item.id ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.operations ).toEqual( expect.arrayContaining( [ OperationType.Upload ] ) ); expect( updatedItem.operations ).not.toEqual( expect.arrayContaining( [ OperationType.ThumbnailGeneration ] ) ); // Server should generate sub-sizes for non-vips files. expect( updatedItem.additionalData.generate_sub_sizes ).toBe( true ); // Server should convert formats (e.g. HEIC to JPEG). expect( updatedItem.additionalData.convert_format ).toBe( true ); } ); it( 'should add only Upload for unsupported image types like SVG', async () => { const svgFile = new File( [ '<svg></svg>' ], 'test.svg', { lastModified: 1234567891, type: 'image/svg+xml', } ); unlock( registry.dispatch( uploadStore ) ).addItem( { file: svgFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; await unlock( registry.dispatch( uploadStore ) ).prepareItem( item.id ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.operations ).toEqual( expect.arrayContaining( [ OperationType.Upload ] ) ); expect( updatedItem.operations ).not.toEqual( expect.arrayContaining( [ OperationType.ThumbnailGeneration ] ) ); expect( updatedItem.additionalData.generate_sub_sizes ).toBe( true ); expect( updatedItem.additionalData.convert_format ).toBe( true ); } ); it( 'should add only Upload for unsupported image types like BMP', async () => { const bmpFile = new File( [ 'bmp' ], 'test.bmp', { lastModified: 1234567891, type: 'image/bmp', } ); unlock( registry.dispatch( uploadStore ) ).addItem( { file: bmpFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; await unlock( registry.dispatch( uploadStore ) ).prepareItem( item.id ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.operations ).toEqual( expect.arrayContaining( [ OperationType.Upload ] ) ); expect( updatedItem.operations ).not.toEqual( expect.arrayContaining( [ OperationType.ThumbnailGeneration ] ) ); expect( updatedItem.additionalData.generate_sub_sizes ).toBe( true ); expect( updatedItem.additionalData.convert_format ).toBe( true ); } ); it( 'should add only Upload for PDF files', async () => { const pdfFile = new File( [ 'pdf' ], 'document.pdf', { lastModified: 1234567891, type: 'application/pdf', } ); unlock( registry.dispatch( uploadStore ) ).addItem( { file: pdfFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; await unlock( registry.dispatch( uploadStore ) ).prepareItem( item.id ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.operations ).toEqual( expect.arrayContaining( [ OperationType.Upload ] ) ); expect( updatedItem.operations ).not.toEqual( expect.arrayContaining( [ OperationType.ThumbnailGeneration ] ) ); expect( updatedItem.additionalData.generate_sub_sizes ).toBe( true ); expect( updatedItem.additionalData.convert_format ).toBe( true ); } ); it( 'routes very large interlaced images to the server', async () => { // A progressive JPEG (SOF2) header reporting 20000x11857, which // exceeds the client-side memory budget for interlaced images. const dimensions = new Uint8Array( 4 ); const dimView = new DataView( dimensions.buffer ); dimView.setUint16( 0, 11857 ); // height dimView.setUint16( 2, 20000 ); // width const bytes = new Uint8Array( [ 0xff, 0xd8, // SOI 0xff, 0xc2, // SOF2 (progressive) 0x00, 0x11, // segment length 0x08, // precision ...dimensions, // height (2) + width (2) 0x03, // components 0x00, 0x00, 0x00, ] ); const largeJpeg = new File( [ bytes ], 'huge.jpg', { type: 'image/jpeg', } ); unlock( registry.dispatch( uploadStore ) ).addItem( { file: largeJpeg, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; await unlock( registry.dispatch( uploadStore ) ).prepareItem( item.id ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Should fall back to server-side processing: only Upload, no // client-side thumbnail generation. expect( updatedItem.operations ).toEqual( expect.arrayContaining( [ OperationType.Upload ] ) ); expect( updatedItem.operations ).not.toEqual( expect.arrayContaining( [ OperationType.ThumbnailGeneration ] ) ); expect( updatedItem.additionalData.generate_sub_sizes ).toBe( true ); expect( updatedItem.additionalData.convert_format ).toBe( true ); } ); } ); describe( 'concurrent sideloads', () => { it( 'does not pause sideload items targeting the same post', async () => { // Configure mediaSideload so sideload uploads can proceed. unlock( registry.dispatch( uploadStore ) ).updateSettings( { mediaSideload: jest.fn(), } ); // Use a fake parentId so we only test sideload scheduling. const fakeParentId = 'fake-parent-id'; // Add two sideload items targeting the same post. unlock( registry.dispatch( uploadStore ) ).addSideloadItem( { file: jpegFile, parentId: fakeParentId, additionalData: { post: 100, image_size: 'thumbnail' }, operations: [ OperationType.Upload ], } ); unlock( registry.dispatch( uploadStore ) ).addSideloadItem( { file: jpegFile, parentId: fakeParentId, additionalData: { post: 100, image_size: 'medium' }, operations: [ OperationType.Upload ], } ); // Resume the queue to trigger processing. await unlock( registry.dispatch( uploadStore ) ).resumeQueue(); const items = unlock( registry.select( uploadStore ) ).getAllItems(); const sideloadItems = items.filter( ( item ) => item.parentId === fakeParentId ); // Neither sideload item should be paused. for ( const item of sideloadItems ) { expect( item.status ).not.toBe( ItemStatus.Paused ); } } ); it( 'allows multiple sideloads to the same attachment to upload concurrently', async () => { const mediaSideload = jest.fn(); unlock( registry.dispatch( uploadStore ) ).updateSettings( { mediaSideload, maxConcurrentUploads: 5, } ); // Add a parent item first. unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const parentItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Add 3 sideload items to same post. for ( const size of [ 'thumbnail', 'medium', 'large' ] ) { unlock( registry.dispatch( uploadStore ) ).addSideloadItem( { file: jpegFile, parentId: parentItem.id, additionalData: { post: 200, image_size: size }, operations: [ OperationType.Upload ], } ); } // Resume the queue. await unlock( registry.dispatch( uploadStore ) ).resumeQueue(); // All 3 sideloads should have started (not serialized). expect( mediaSideload ).toHaveBeenCalledTimes( 3 ); } ); it( 'respects maxConcurrentUploads for sideloads', async () => { const mediaSideload = jest.fn(); unlock( registry.dispatch( uploadStore ) ).updateSettings( { mediaSideload, maxConcurrentUploads: 2, } ); // Use a fake parentId so the parent item does not consume // an upload slot. Only sideload items compete for slots. const fakeParentId = 'fake-parent-id'; // Add 4 sideload items. for ( const size of [ 'thumbnail', 'medium', 'large', 'medium_large', ] ) { unlock( registry.dispatch( uploadStore ) ).addSideloadItem( { file: jpegFile, parentId: fakeParentId, additionalData: { post: 300, image_size: size }, operations: [ OperationType.Upload ], } ); } // Resume the queue. await unlock( registry.dispatch( uploadStore ) ).resumeQueue(); // Only 2 should have started due to concurrency limit. expect( mediaSideload ).toHaveBeenCalledTimes( 2 ); } ); it( 'starts pending sideloads after one finishes', async () => { let onSuccessCallback: | ( ( subSize: Record< string, unknown > ) => void ) | undefined; const mediaSideload = jest.fn( ( { onSuccess } ) => { // Capture the first callback to simulate completion later. if ( ! onSuccessCallback ) { onSuccessCallback = onSuccess; } } ); unlock( registry.dispatch( uploadStore ) ).updateSettings( { mediaSideload, maxConcurrentUploads: 1, } ); // Use a fake parentId so the parent item does not consume // an upload slot. const fakeParentId = 'fake-parent-id'; // Add 2 sideload items. unlock( registry.dispatch( uploadStore ) ).addSideloadItem( { file: jpegFile, parentId: fakeParentId, additionalData: { post: 400, image_size: 'thumbnail' }, operations: [ OperationType.Upload ], } ); unlock( registry.dispatch( uploadStore ) ).addSideloadItem( { file: jpegFile, parentId: fakeParentId, additionalData: { post: 400, image_size: 'medium' }, operations: [ OperationType.Upload ], } ); // Resume the queue. await unlock( registry.dispatch( uploadStore ) ).resumeQueue(); // Only 1 should have started due to maxConcurrentUploads=1. expect( mediaSideload ).toHaveBeenCalledTimes( 1 ); // Complete the first upload to trigger the pending one. onSuccessCallback?.( { image_size: 'thumbnail', width: 150, height: 150, file: 'image-150x150.jpg', mime_type: 'image/jpeg', filesize: 5000, } ); // Allow async dispatch to propagate. await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); // The second sideload should now have started. expect( mediaSideload ).toHaveBeenCalledTimes( 2 ); } ); it( 'resumeItemByPostId is not on private dispatch', () => { const privateDispatch = unlock( registry.dispatch( uploadStore ) ); expect( ( privateDispatch as Record< string, unknown > ) .resumeItemByPostId ).toBeUndefined(); } ); } ); describe( 'cancelItem', () => { beforeEach( () => { ( vipsCancelOperations as jest.Mock ).mockClear(); ( cancelGifToVideoOperations as jest.Mock ).mockClear(); } ); it( 'calls vipsCancelOperations when cancelling', async () => { // Suppress console.error that fires when there's no onError callback. const consoleErrorSpy = jest .spyOn( console, 'error' ) .mockImplementation( () => {} ); unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; await registry .dispatch( uploadStore ) .cancelItem( item.id, new Error( 'User cancelled' ) ); expect( vipsCancelOperations ).toHaveBeenCalledWith( item.id ); expect( consoleErrorSpy ).toHaveBeenCalled(); consoleErrorSpy.mockRestore(); } ); it( 'cancels any in-flight GIF-to-video conversion when cancelling', async () => { // Suppress console.error that fires when there's no onError callback. const consoleErrorSpy = jest .spyOn( console, 'error' ) .mockImplementation( () => {} ); unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; await registry .dispatch( uploadStore ) .cancelItem( item.id, new Error( 'User cancelled' ) ); expect( cancelGifToVideoOperations ).toHaveBeenCalledWith( item.id ); consoleErrorSpy.mockRestore(); } ); it( 'removes item from queue after cancelling', async () => { // Suppress console.error that fires when there's no onError callback. const consoleErrorSpy = jest .spyOn( console, 'error' ) .mockImplementation( () => {} ); unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; await registry .dispatch( uploadStore ) .cancelItem( item.id, new Error( 'User cancelled' ) ); expect( unlock( registry.select( uploadStore ) ).getAllItems() ).toHaveLength( 0 ); consoleErrorSpy.mockRestore(); } ); it( 'calls onError callback when not silent', async () => { const onError = jest.fn(); unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, onError, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; await registry .dispatch( uploadStore ) .cancelItem( item.id, new Error( 'Test error' ) ); expect( onError ).toHaveBeenCalledWith( expect.objectContaining( { message: 'Test error' } ) ); } ); it( 'does not call onError when silent', async () => { const onError = jest.fn(); unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, onError, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; await registry .dispatch( uploadStore ) .cancelItem( item.id, new Error( 'Test error' ), true ); expect( onError ).not.toHaveBeenCalled(); } ); describe( 'parent cancellation when child sideload fails', () => { // Helpers used by every scenario below. Set up a parent that // has finished its primary upload (so it has an attachment.id), // then add a sideload child that we'll cancel to trigger the // parent-cancel branch. const setUpParentAndChild = ( { parentSubSizes, parentOnError, imageSize = 'medium', }: { parentSubSizes?: { name: string; id: number }[]; parentOnError?: jest.Mock; imageSize?: string; } = {} ) => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, onError: parentOnError, operations: [ OperationType.Finalize ], } ); const parent = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Simulate the parent's primary upload having completed: // give it an attachment.id and (optionally) accumulated // sub-sizes from already-successful child sideloads. unlock( registry.dispatch( uploadStore ) ).finishOperation( parent.id, { attachment: { id: 42 }, ...( parentSubSizes ? { subSizes: parentSubSizes } : {} ), } ); unlock( registry.dispatch( uploadStore ) ).addSideloadItem( { file: jpegFile, parentId: parent.id, additionalData: { post: 42, image_size: imageSize }, } ); const child = unlock( registry.select( uploadStore ) ) .getAllItems() .find( ( i ) => i.parentId === parent.id ); return { parent, child }; }; it( 'deletes parent attachment and cancels parent for vips processing failures with no successful siblings', async () => { const consoleErrorSpy = jest .spyOn( console, 'error' ) .mockImplementation( () => {} ); const mediaDelete = jest.fn().mockResolvedValue( undefined ); const parentOnError = jest.fn(); unlock( registry.dispatch( uploadStore ) ).updateSettings( { mediaDelete, } ); const { parent, child } = setUpParentAndChild( { parentOnError, } ); // resizeCropItem and rotateItem already wrap vips // failures in an UploadError that carries the // actionable user-facing message at the source. const vipsError = new ( jest.requireActual( '../../upload-error' ).UploadError )( { code: 'IMAGE_TRANSCODING_ERROR', message: 'The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading.', file: jpegFile, } ); await registry .dispatch( uploadStore ) .cancelItem( child!.id, vipsError ); expect( mediaDelete ).toHaveBeenCalledWith( 42 ); expect( parentOnError ).toHaveBeenCalledWith( expect.objectContaining( { code: 'IMAGE_TRANSCODING_ERROR', message: expect.stringContaining( 'cannot generate responsive image sizes' ), } ) ); expect( unlock( registry.select( uploadStore ) ).getItem( parent.id ) ).toBeUndefined(); consoleErrorSpy.mockRestore(); } ); it( 'propagates the underlying error message for non-vips sideload failures', async () => { const mediaDelete = jest.fn().mockResolvedValue( undefined ); const parentOnError = jest.fn(); unlock( registry.dispatch( uploadStore ) ).updateSettings( { mediaDelete, } ); const { child } = setUpParentAndChild( { parentOnError } ); const networkError = new ( jest.requireActual( '../../upload-error' ).UploadError )( { code: 'GENERAL', message: 'Network request failed: 503', file: jpegFile, } ); await registry .dispatch( uploadStore ) .cancelItem( child!.id, networkError ); expect( mediaDelete ).toHaveBeenCalledWith( 42 ); expect( parentOnError ).toHaveBeenCalledWith( expect.objectContaining( { code: 'GENERAL', message: 'Network request failed: 503', } ) ); } ); it( 'preserves the parent attachment when at least one sibling sub-size succeeded', async () => { const mediaDelete = jest.fn().mockResolvedValue( undefined ); const parentOnError = jest.fn(); unlock( registry.dispatch( uploadStore ) ).updateSettings( { mediaDelete, } ); const { parent, child } = setUpParentAndChild( { parentOnError, parentSubSizes: [ { name: 'medium', id: 99 } ], } ); const networkError = new ( jest.requireActual( '../../upload-error' ).UploadError )( { code: 'GENERAL', message: 'sideload of large size failed', file: jpegFile, } ); await registry .dispatch( uploadStore ) .cancelItem( child!.id, networkError ); // Partial success: do NOT delete the parent attachment, // do NOT cancel the parent. The accumulated sub-sizes // will still be sent to the finalize endpoint. expect( mediaDelete ).not.toHaveBeenCalled(); expect( parentOnError ).not.toHaveBeenCalled(); expect( unlock( registry.select( uploadStore ) ).getItem( parent.id ) ).toBeDefined(); } ); it( 'falls back to a generic message when the underlying error has no message', async () => { const mediaDelete = jest.fn().mockResolvedValue( undefined ); const parentOnError = jest.fn(); unlock( registry.dispatch( uploadStore ) ).updateSettings( { mediaDelete, } ); const { child } = setUpParentAndChild( { parentOnError } ); await registry .dispatch( uploadStore ) .cancelItem( child!.id, new Error( '' ) ); expect( parentOnError ).toHaveBeenCalledWith( expect.objectContaining( { message: 'The image could not be uploaded.', } ) ); } ); it( 'keeps the parent GIF when its only child is a failed animated_video companion', async () => { const mediaDelete = jest.fn().mockResolvedValue( undefined ); const parentOnError = jest.fn(); unlock( registry.dispatch( uploadStore ) ).updateSettings( { mediaDelete, } ); // The converted video is the sole child of the GIF and has no // accumulated sub-sizes — the pre-fix "total failure" case. const { parent, child } = setUpParentAndChild( { parentOnError, imageSize: 'animated_video', } ); await registry .dispatch( uploadStore ) .cancelItem( child!.id, new Error( 'conversion failed' ), true ); // The optional companion failing must not delete or cancel the // already-uploaded GIF attachment. expect( mediaDelete ).not.toHaveBeenCalled(); expect( parentOnError ).not.toHaveBeenCalled(); expect( unlock( registry.select( uploadStore ) ).getItem( parent.id ) ).toBeDefined(); } ); } ); } ); describe( 'retryItem', () => { beforeEach( () => { unlock( registry.dispatch( uploadStore ) ).updateSettings( { retry: { maxRetryAttempts: 3, initialRetryDelayMs: 1000, maxRetryDelayMs: 30000, backoffMultiplier: 2, retryJitter: 0.1, }, } ); } ); it( 'does nothing for non-existent item', async () => { await registry .dispatch( uploadStore ) .retryItem( 'non-existent-id' ); expect( unlock( registry.select( uploadStore ) ).getAllItems() ).toHaveLength( 0 ); } ); it( 'does nothing for item without error', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Item has no error, so retryItem should do nothing. await registry.dispatch( uploadStore ).retryItem( item.id ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.status ).toBe( ItemStatus.Processing ); expect( updatedItem.retryCount ).toBeUndefined(); } ); it( 'sets status to Processing and clears error', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Schedule retry to put item in PendingRetry status with error. await registry .dispatch( uploadStore ) .scheduleRetry( item.id, new Error( 'Network error' ) ); // Retry the item. await registry.dispatch( uploadStore ).retryItem( item.id ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.status ).toBe( ItemStatus.Processing ); expect( updatedItem.error ).toBeUndefined(); } ); it( 'increments retryCount', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Schedule retry to put item in error state. await registry .dispatch( uploadStore ) .scheduleRetry( item.id, new Error( 'Network error' ) ); // Retry the item. await registry.dispatch( uploadStore ).retryItem( item.id ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.retryCount ).toBe( 1 ); } ); } ); describe( 'cancelItem retry integration', () => { beforeEach( () => { jest.useFakeTimers(); ( vipsCancelOperations as jest.Mock ).mockClear(); unlock( registry.dispatch( uploadStore ) ).updateSettings( { retry: { maxRetryAttempts: 3, initialRetryDelayMs: 1000, maxRetryDelayMs: 30000, backoffMultiplier: 2, retryJitter: 0.1, }, } ); } ); afterEach( () => { jest.useRealTimers(); } ); it( 'schedules retry for retryable errors', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Cancel with a retryable error (network error pattern). await registry .dispatch( uploadStore ) .cancelItem( item.id, new Error( 'Network error' ) ); // Item should still be in the queue with PendingRetry status. const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem ).toBeDefined(); expect( updatedItem.status ).toBe( ItemStatus.PendingRetry ); } ); it( 'does NOT schedule retry when silent=true', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Cancel silently with a retryable error. await registry .dispatch( uploadStore ) .cancelItem( item.id, new Error( 'Network error' ), true ); // Item should be removed (not retried). expect( unlock( registry.select( uploadStore ) ).getAllItems() ).toHaveLength( 0 ); } ); it( 'does NOT schedule retry for non-retryable errors', async () => { const consoleErrorSpy = jest .spyOn( console, 'error' ) .mockImplementation( () => {} ); unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Cancel with a non-retryable error. await registry .dispatch( uploadStore ) .cancelItem( item.id, new Error( 'File validation failed' ) ); // Item should be removed (not retried). expect( unlock( registry.select( uploadStore ) ).getAllItems() ).toHaveLength( 0 ); consoleErrorSpy.mockRestore(); } ); it( 'does NOT schedule retry when retry settings are undefined', async () => { const consoleErrorSpy = jest .spyOn( console, 'error' ) .mockImplementation( () => {} ); // Disable retry settings. unlock( registry.dispatch( uploadStore ) ).updateSettings( { retry: undefined, } ); unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Cancel with a retryable error. await registry .dispatch( uploadStore ) .cancelItem( item.id, new Error( 'Network error' ) ); // Item should be removed (retry not available without settings). expect( unlock( registry.select( uploadStore ) ).getAllItems() ).toHaveLength( 0 ); consoleErrorSpy.mockRestore(); } ); it( 'clears pending retry timer on manual cancel', async () => { const onError = jest.fn(); unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, onError, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Schedule a retry to put item in PendingRetry with a pending timer. await registry .dispatch( uploadStore ) .cancelItem( item.id, new Error( 'Network error' ) ); const pendingItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( pendingItem.status ).toBe( ItemStatus.PendingRetry ); // Now manually cancel the item while it's pending retry. await registry .dispatch( uploadStore ) .cancelItem( item.id, new Error( 'Manual cancel' ), true ); // Item should be removed from queue. expect( unlock( registry.select( uploadStore ) ).getAllItems() ).toHaveLength( 0 ); // Advance timers — the old retry timer should NOT fire. await jest.runAllTimersAsync(); // Queue should still be empty (timer was cleared). expect( unlock( registry.select( uploadStore ) ).getAllItems() ).toHaveLength( 0 ); } ); } ); describe( 'scheduleRetry', () => { beforeEach( () => { jest.useFakeTimers(); unlock( registry.dispatch( uploadStore ) ).updateSettings( { retry: { maxRetryAttempts: 3, initialRetryDelayMs: 1000, maxRetryDelayMs: 30000, backoffMultiplier: 2, retryJitter: 0.1, }, } ); } ); afterEach( () => { jest.useRealTimers(); } ); it( 'sets item status to PendingRetry', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; await registry .dispatch( uploadStore ) .scheduleRetry( item.id, new Error( 'Network error' ) ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.status ).toBe( ItemStatus.PendingRetry ); } ); it( 'stores the error on the item', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; const error = new Error( 'Network error' ); await registry .dispatch( uploadStore ) .scheduleRetry( item.id, error ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.error ).toBe( error ); } ); it( 'sets nextRetryTimestamp', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; const beforeTime = Date.now(); await registry .dispatch( uploadStore ) .scheduleRetry( item.id, new Error( 'Network error' ) ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.nextRetryTimestamp ).toBeGreaterThan( beforeTime ); } ); it( 'does nothing if item does not exist', async () => { await registry .dispatch( uploadStore ) .scheduleRetry( 'non-existent-id', new Error( 'Network error' ) ); expect( unlock( registry.select( uploadStore ) ).getAllItems() ).toHaveLength( 0 ); } ); it( 'executes retry after timer fires', async () => { // executeRetry is now a no-op when the queue is paused (the outer // beforeEach pauses); resume so the timer's executeRetry mutates // state. await unlock( registry.dispatch( uploadStore ) ).resumeQueue(); unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; await registry .dispatch( uploadStore ) .scheduleRetry( item.id, new Error( 'Network error' ) ); // Item should be in PendingRetry status. let updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.status ).toBe( ItemStatus.PendingRetry ); // Fire all timers to trigger executeRetry. await jest.runAllTimersAsync(); // Item should now be back in Processing status with incremented retryCount. updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.status ).toBe( ItemStatus.Processing ); expect( updatedItem.retryCount ).toBe( 1 ); } ); } ); describe( 'executeRetry', () => { beforeEach( async () => { jest.useFakeTimers(); unlock( registry.dispatch( uploadStore ) ).updateSettings( { retry: { maxRetryAttempts: 3, initialRetryDelayMs: 1000, maxRetryDelayMs: 30000, backoffMultiplier: 2, retryJitter: 0.1, }, } ); // executeRetry is now a no-op when the queue is paused (the outer // beforeEach pauses); resume so executeRetry mutates state. await unlock( registry.dispatch( uploadStore ) ).resumeQueue(); } ); afterEach( () => { jest.useRealTimers(); } ); it( 'resets item to Processing status', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // First schedule a retry to put item in PendingRetry status. await registry .dispatch( uploadStore ) .scheduleRetry( item.id, new Error( 'Network error' ) ); // Execute the retry. await registry.dispatch( uploadStore ).executeRetry( item.id ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.status ).toBe( ItemStatus.Processing ); } ); it( 'clears the error on the item', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Schedule retry to set error. await registry .dispatch( uploadStore ) .scheduleRetry( item.id, new Error( 'Network error' ) ); // Verify error is set. let updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.error ).toBeDefined(); // Execute retry. await registry.dispatch( uploadStore ).executeRetry( item.id ); updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.error ).toBeUndefined(); } ); it( 'increments retryCount', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Schedule retry (sets retryCount to current value). await registry .dispatch( uploadStore ) .scheduleRetry( item.id, new Error( 'Network error' ) ); // Execute retry (increments retryCount). await registry.dispatch( uploadStore ).executeRetry( item.id ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.retryCount ).toBe( 1 ); } ); it( 'creates a fresh AbortController after retry', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; const originalController = item.abortController; // Schedule retry to put item in PendingRetry status. await registry .dispatch( uploadStore ) .scheduleRetry( item.id, new Error( 'Network error' ) ); // Execute the retry. await registry.dispatch( uploadStore ).executeRetry( item.id ); const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Should have a new AbortController instance. expect( updatedItem.abortController ).toBeInstanceOf( AbortController ); expect( updatedItem.abortController ).not.toBe( originalController ); // The new controller should not be aborted. expect( updatedItem.abortController?.signal.aborted ).toBe( false ); } ); it( 'does nothing if item does not exist', async () => { await registry .dispatch( uploadStore ) .executeRetry( 'non-existent-id' ); // Should not throw, just return silently. expect( unlock( registry.select( uploadStore ) ).getAllItems() ).toHaveLength( 0 ); } ); it( 'removeItem clears any pending retry timer', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Schedule a retry to put a timer in the retryTimers map. await registry .dispatch( uploadStore ) .scheduleRetry( item.id, new Error( 'Network error' ) ); expect( unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ] .status ).toBe( ItemStatus.PendingRetry ); // Remove the item directly (not via cancelItem). await unlock( registry.dispatch( uploadStore ) ).removeItem( item.id ); // Item should be gone. expect( unlock( registry.select( uploadStore ) ).getAllItems() ).toHaveLength( 0 ); // Advance timers — the old retry timer must NOT re-add or // touch the item. await jest.runAllTimersAsync(); expect( unlock( registry.select( uploadStore ) ).getAllItems() ).toHaveLength( 0 ); } ); it( 'falls through to cancellation after exhausting max retries', async () => { const onError = jest.fn(); unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, onError, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // First failure with retryable error → schedules retry (count=0). await registry .dispatch( uploadStore ) .cancelItem( item.id, new Error( 'Network error' ) ); expect( unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ] .status ).toBe( ItemStatus.PendingRetry ); // Run through the 3 scheduled retries — each fires the timer, // executes the retry (incrementing retryCount), then we simulate // another failure. for ( let attempt = 1; attempt <= 3; attempt++ ) { await jest.runAllTimersAsync(); const inProgress = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( inProgress.status ).toBe( ItemStatus.Processing ); expect( inProgress.retryCount ).toBe( attempt ); await registry .dispatch( uploadStore ) .cancelItem( item.id, new Error( 'Network error' ) ); } // After max retries exhausted, the next cancel should remove // the item and surface the error to onError. expect( unlock( registry.select( uploadStore ) ).getAllItems() ).toHaveLength( 0 ); expect( onError ).toHaveBeenCalledWith( expect.objectContaining( { message: 'Network error' } ) ); } ); it( 'does nothing if item is not in PendingRetry status', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Item is in Processing status, not PendingRetry. expect( item.status ).toBe( ItemStatus.Processing ); // Execute retry should do nothing. await registry.dispatch( uploadStore ).executeRetry( item.id ); // Status should remain unchanged. const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.status ).toBe( ItemStatus.Processing ); expect( updatedItem.retryCount ).toBeUndefined(); } ); it( 'leaves item in PendingRetry when queue is paused, then resumes on resumeQueue', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Schedule a retry to put item in PendingRetry. await registry .dispatch( uploadStore ) .scheduleRetry( item.id, new Error( 'Network error' ) ); // Pause the queue before the timer fires. unlock( registry.dispatch( uploadStore ) ).pauseQueue(); // Fire the retry timer while paused — executeRetry should bail // without mutating state. await jest.runAllTimersAsync(); const pausedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( pausedItem.status ).toBe( ItemStatus.PendingRetry ); expect( pausedItem.retryCount ).toBe( 0 ); // Resume — resumeQueue should re-trigger executeRetry for any // PendingRetry items so they actually process. await unlock( registry.dispatch( uploadStore ) ).resumeQueue(); const resumedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( resumedItem.status ).toBe( ItemStatus.Processing ); expect( resumedItem.retryCount ).toBe( 1 ); } ); } ); describe( 'resizeCropItem', () => { it( 'uses imageQuality from store settings when set', async () => { unlock( registry.dispatch( uploadStore ) ).updateSettings( { imageQuality: 0.5, } ); unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; const { vipsResizeImage } = require( '../utils' ); ( vipsResizeImage as jest.Mock ).mockClear(); await unlock( registry.dispatch( uploadStore ) ).resizeCropItem( item.id, { resize: { width: 100, height: 100 } } ); // Verify the resize was called (quality will be wired through in a future update). expect( vipsResizeImage ).toHaveBeenCalled(); } ); it( 'falls back to default quality when imageQuality is not set', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; const { vipsResizeImage } = require( '../utils' ); ( vipsResizeImage as jest.Mock ).mockClear(); await unlock( registry.dispatch( uploadStore ) ).resizeCropItem( item.id, { resize: { width: 100, height: 100 } } ); expect( vipsResizeImage ).toHaveBeenCalled(); } ); it( 'skips resize when no resize args are provided', async () => { unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; await unlock( registry.dispatch( uploadStore ) ).resizeCropItem( item.id ); // Item should finish without resize. const updatedItem = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; expect( updatedItem.file ).toBe( jpegFile ); } ); } ); describe( 'generateThumbnails', () => { const mockBitmapClose = jest.fn(); function mockCreateImageBitmap( width: number, height: number ) { global.createImageBitmap = jest.fn( () => Promise.resolve( { width, height, close: mockBitmapClose, } ) ) as unknown as typeof global.createImageBitmap; } /** * Sets up an item in the queue with an attachment and * ThumbnailGeneration as the current operation, simulating * the state after upload completes. * * @param overrides Optional overrides. * @param overrides.attachment Attachment field overrides. */ async function setupItemForThumbnailGeneration( overrides?: { attachment?: Record< string, unknown >; } ) { // Add an item with ThumbnailGeneration as the only operation. unlock( registry.dispatch( uploadStore ) ).addItem( { file: jpegFile, operations: [ OperationType.ThumbnailGeneration ], } ); const item = unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; // Simulate upload completion by finishing the operation with attachment data. // finishOperation shifts the operations array, so ThumbnailGeneration // becomes the next operation to process. await unlock( registry.dispatch( uploadStore ) ).finishOperation( item.id, { attachment: { id: 123, filename: 'example.jpg', missing_image_sizes: [ 'thumbnail', 'medium' ], ...( overrides?.attachment || {} ), }, } ); return unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]; } beforeEach( () => { mockBitmapClose.mockClear(); } ); afterEach( () => { // Clean up global mock. // @ts-ignore delete global.createImageBitmap; } ); it( 'should not sideload a scaled version when image is below the threshold', async () => { // Image is 800x600, threshold is 2560. mockCreateImageBitmap( 800, 600 ); unlock( registry.dispatch( uploadStore ) ).updateSettings( { bigImageSizeThreshold: 2560, allImageSizes: { thumbnail: { width: 150, height: 150 }, medium: { width: 300, height: 300 }, }, } ); const item = await setupItemForThumbnailGeneration(); await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( item.id ); const allItems = unlock( registry.select( uploadStore ) ).getAllItems(); // Should have sideload items for thumbnails, but NOT for 'scaled'. const scaledItems = allItems.filter( ( i ) => i.additionalData?.image_size === 'scaled' ); expect( scaledItems ).toHaveLength( 0 ); expect( mockBitmapClose ).toHaveBeenCalled(); } ); it( 'should sideload a scaled version when image exceeds the threshold', async () => { // Image is 4000x3000, threshold is 2560. mockCreateImageBitmap( 4000, 3000 ); unlock( registry.dispatch( uploadStore ) ).updateSettings( { bigImageSizeThreshold: 2560, allImageSizes: { thumbnail: { width: 150, height: 150 }, medium: { width: 300, height: 300 }, }, } ); const item = await setupItemForThumbnailGeneration(); await unlock( registry.dispatch( uploadStore ) ).generateThumbnails( item.id ); const allItems = unlock( registry.select( uploadStore )