Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion apps/studio/src/stores/sync/sync-operations-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ),
Expand Down Expand Up @@ -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 {
Expand Down
176 changes: 176 additions & 0 deletions apps/studio/src/stores/tests/sync-operations-slice.test.ts
Original file line number Diff line number Diff line change
@@ -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();
} );
} );
} );
Loading