diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index 0e1e458446..9be2bd9425 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -490,6 +490,25 @@ const cancelPullThunk = createTypedAsyncThunk( } ); +const BACKUP_ALREADY_IN_PROGRESS_CODE = 'backup_already_in_progress'; + +function isBackupAlreadyInProgressError( error: unknown ): boolean { + if ( typeof error !== 'object' || error === null ) { + return false; + } + const { error: errorCode, code } = error as { error?: unknown; code?: unknown }; + return errorCode === BACKUP_ALREADY_IN_PROGRESS_CODE || code === BACKUP_ALREADY_IN_PROGRESS_CODE; +} + +function getBackupAlreadyInProgressRejection( siteName: string ) { + return { + title: sprintf( __( 'Error pulling from %s' ), siteName ), + message: __( + 'A backup is already in progress for this site. Please wait a few minutes and try again.' + ), + }; +} + const getErrorFromResponse = ( error: unknown ): string => { if ( typeof error === 'object' && @@ -666,6 +685,15 @@ export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayl } ); const response = pullSiteResponseSchema.parse( rawResponse ); + // Servers predating the `backup_already_in_progress` 409 report a deduped + // request as `success: true` with `backup_id: 0` (STU-2098). + if ( response.success && ! response.backup_id ) { + console.error( + `Pull rejected: a backup is already in progress for remote site ${ remoteSiteId }` + ); + return rejectWithValue( getBackupAlreadyInProgressRejection( connectedSite.name ) ); + } + if ( response.success ) { // Creating the remote backup can take a while. If the user logged out (slice // reset) or cancelled the pull while this request was in flight, don't resurrect @@ -698,6 +726,12 @@ export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayl throw new Error( 'Pull request failed' ); } } catch ( error ) { + if ( isBackupAlreadyInProgressError( error ) ) { + console.error( + `Pull rejected: a backup is already in progress for remote site ${ remoteSiteId }` + ); + return rejectWithValue( getBackupAlreadyInProgressRejection( connectedSite.name ) ); + } Sentry.captureException( error ); return rejectWithValue( { title: sprintf( __( 'Error pulling from %s' ), connectedSite.name ), @@ -871,7 +905,10 @@ const pollPullBackupThunk = createTypedAsyncThunk( const backupId = currentPullState.backupId; if ( ! backupId ) { console.error( 'No backup ID found' ); - return; + return rejectWithValue( { + title: sprintf( __( 'Error pulling from %s' ), currentPullState.selectedSite.name ), + message: __( 'An error occurred while checking the backup status. Please try again.' ), + } ); } try { diff --git a/apps/studio/src/stores/tests/sync-operations-slice.test.ts b/apps/studio/src/stores/tests/sync-operations-slice.test.ts new file mode 100644 index 0000000000..ea8fc965da --- /dev/null +++ b/apps/studio/src/stores/tests/sync-operations-slice.test.ts @@ -0,0 +1,176 @@ +import { vi } from 'vitest'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { store } from 'src/stores'; +import { + syncOperationsActions, + syncOperationsSelectors, + syncOperationsThunks, + getPullStatesProgressInfo, +} from 'src/stores/sync/sync-operations-slice'; +import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; +import type { SyncSite } from '@studio/common/types/sync'; +import type { WPCOM } from 'wpcom/types'; + +vi.mock( 'src/lib/get-ipc-api', () => ( { + getIpcApi: vi.fn().mockReturnValue( { + showErrorMessageBox: vi.fn(), + showNotification: vi.fn(), + fetchSnapshots: vi.fn().mockResolvedValue( [] ), + getConnectedWpcomSites: vi.fn().mockResolvedValue( [] ), + updateConnectedWpcomSites: vi.fn(), + cancelSyncOperation: vi.fn(), + } ), +} ) ); + +const mockShowErrorMessageBox = vi.mocked( getIpcApi )().showErrorMessageBox as ReturnType< + typeof vi.fn +>; + +const selectedSite = { id: 'local-site-1', name: 'My Local Site' } as SiteDetails; +const connectedSite = { + id: 256266481, + localSiteId: 'local-site-1', + name: 'My Remote Site', + url: 'https://example.com', +} as SyncSite; + +function createMockClient( postResponse?: unknown ) { + return { + req: { + post: vi.fn().mockResolvedValue( postResponse ), + get: vi.fn(), + }, + } as unknown as WPCOM; +} + +store.replaceReducer( testReducer ); + +describe( 'syncOperations pull thunks', () => { + beforeEach( () => { + vi.clearAllMocks(); + store.dispatch( testActions.resetState() ); + } ); + + describe( 'pullSite', () => { + it( 'stores the backup ID and fulfills when the server returns one', async () => { + const client = createMockClient( { success: true, backup_id: 12345 } ); + + const result = await store.dispatch( + syncOperationsThunks.pullSite( { + client, + connectedSite, + selectedSite, + options: { optionsToSync: [ 'all' ] }, + } ) + ); + + expect( result.type ).toBe( 'syncOperations/pullSite/fulfilled' ); + const pullState = syncOperationsSelectors.selectPullState( + selectedSite.id, + connectedSite.id + )( store.getState() ); + expect( pullState?.backupId ).toBe( 12345 ); + } ); + + it( 'rejects with a "backup already in progress" error when the server responds 409 backup_already_in_progress', async () => { + const client = createMockClient(); + ( client.req.post as ReturnType< typeof vi.fn > ).mockRejectedValue( { + error: 'backup_already_in_progress', + message: 'A backup is already in progress for this site.', + statusCode: 409, + } ); + + const result = await store.dispatch( + syncOperationsThunks.pullSite( { + client, + connectedSite, + selectedSite, + options: { optionsToSync: [ 'all' ] }, + } ) + ); + + expect( result.type ).toBe( 'syncOperations/pullSite/rejected' ); + expect( ( result.payload as { message: string } ).message ).toMatch( + /backup is already in progress/i + ); + + const pullState = syncOperationsSelectors.selectPullState( + selectedSite.id, + connectedSite.id + )( store.getState() ); + expect( pullState?.status.key ).toBe( 'failed' ); + expect( mockShowErrorMessageBox ).toHaveBeenCalledWith( + expect.objectContaining( { + message: expect.stringMatching( /backup is already in progress/i ), + } ) + ); + } ); + + it( 'rejects with a "backup already in progress" error when the server dedupes the request with backup_id 0', async () => { + const client = createMockClient( { success: true, backup_id: 0 } ); + + const result = await store.dispatch( + syncOperationsThunks.pullSite( { + client, + connectedSite, + selectedSite, + options: { optionsToSync: [ 'all' ] }, + } ) + ); + + expect( result.type ).toBe( 'syncOperations/pullSite/rejected' ); + expect( ( result.payload as { message: string } ).message ).toMatch( + /backup is already in progress/i + ); + + const pullState = syncOperationsSelectors.selectPullState( + selectedSite.id, + connectedSite.id + )( store.getState() ); + expect( pullState?.status.key ).toBe( 'failed' ); + expect( mockShowErrorMessageBox ).toHaveBeenCalledWith( + expect.objectContaining( { + message: expect.stringMatching( /backup is already in progress/i ), + } ) + ); + } ); + } ); + + describe( 'pollPullBackup', () => { + it( 'rejects and marks the pull as failed when the state has no backup ID', async () => { + const client = createMockClient(); + store.dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId: selectedSite.id, + remoteSiteId: connectedSite.id, + state: { + backupId: null, + status: getPullStatesProgressInfo()[ 'in-progress' ], + downloadUrl: null, + remoteSiteUrl: connectedSite.url, + selectedSite, + }, + } ) + ); + + const result = await store.dispatch( + syncOperationsThunks.pollPullBackup( { + client, + selectedSiteId: selectedSite.id, + remoteSiteId: connectedSite.id, + signal: new AbortController().signal, + } ) + ); + + expect( result.type ).toBe( 'syncOperations/pollPullBackup/rejected' ); + expect( client.req.get ).not.toHaveBeenCalled(); + + const pullState = syncOperationsSelectors.selectPullState( + selectedSite.id, + connectedSite.id + )( store.getState() ); + expect( pullState?.status.key ).toBe( 'failed' ); + expect( mockShowErrorMessageBox ).toHaveBeenCalled(); + } ); + } ); +} );