Commit c7756939030 for woocommerce
commit c7756939030e78748b54fd09ff674126b9217081
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date: Fri Jul 10 11:12:41 2026 +0300
Fix NOX test account step retry loop on non-recoverable errors (#66356)
* Fix NOX test account step retry loop on non-recoverable errors
When the WooPayments test account initialization fails in a way the
merchant cannot fix (e.g. Stripe rejecting the auto-generated statement
descriptor with invalid_request_error, or the platform falling back to
an account that requires verification and reporting success=false), the
step was marked failed and the merchant was stuck on a Try Again loop
that could never succeed.
Detect these non-recoverable failures in onboarding_test_account_init():
mark the step completed instead of failed, record a tracks event, and
throw a dedicated error code. The test account step UI handles that code
by showing a snackbar and advancing to the next onboarding step.
* Add changelog entry for NOX test account non-recoverable handling
* Address code review feedback on non-recoverable error handling
- Log when the skipped test account step completion fails to persist,
so drift between client navigation and stored onboarding state is
debuggable instead of silent.
- Rename the non-recoverable error constant to ..._ERROR_IDENTIFIERS
since its entries are matched against both error types and codes.
- Allow extending the identifier list via the new
woocommerce_woopayments_onboarding_test_account_non_recoverable_errors
filter, so the taxonomy can grow without a core release.
- Pin the init request count in the success:false test; add tests for
the filter and for the persistence-failure logging path.
* Fail instead of advancing when the test account skip does not persist
When skipping the test account step forward on a non-recoverable error,
the completed status write could fail while the client still received
the skip-forward error code, showing a success-flavored snackbar and
advancing the merchant while the stored step stayed in started.
Fall back to the regular failure handling in that window: mark the step
failed and throw the standard client API error so the merchant gets the
retry UI, and a later attempt can complete the skip. Also broaden the
record_onboarding_step_completed() docblock to cover syncing state
after failed operations, and turn the non-recoverable matcher test into
a data provider covering error type/code in nested data and top-level
positions.
diff --git a/plugins/woocommerce/changelog/fix-59437-nox-test-account-non-recoverable b/plugins/woocommerce/changelog/fix-59437-nox-test-account-non-recoverable
new file mode 100644
index 00000000000..95f0f2596b0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-59437-nox-test-account-non-recoverable
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+NOX onboarding: automatically move to the next step and show a notice when the test account creation fails with a non-recoverable error, instead of a retry loop.
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-account/index.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-account/index.tsx
index fa07204b837..ae37e979b7f 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-account/index.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-account/index.tsx
@@ -21,6 +21,8 @@ import './style.scss';
const TEST_ACCOUNT_ERROR_CODES = {
ACCOUNT_ALREADY_EXISTS:
'woocommerce_woopayments_test_account_already_exists',
+ NON_RECOVERABLE_ERROR:
+ 'woocommerce_woopayments_onboarding_test_account_non_recoverable_error',
};
interface StepCheckResponse {
@@ -95,6 +97,7 @@ const TestAccountStep = () => {
setJustCompletedStepId,
sessionEntryPoint,
setSnackbar,
+ navigateToNextStep,
} = useOnboardingContext();
// Component State.
@@ -254,6 +257,24 @@ const TestAccountStep = () => {
}
} )
.catch( ( error ) => {
+ if (
+ error?.code ===
+ TEST_ACCOUNT_ERROR_CODES.NON_RECOVERABLE_ERROR
+ ) {
+ // The test account could not be created and retrying can't fix it.
+ // The backend already marked the step completed, so notify the
+ // merchant and move them forward to the next step.
+ setSnackbar( {
+ show: true,
+ message: __(
+ "We couldn't create a test account, so we're taking you to the next step.",
+ 'woocommerce'
+ ),
+ } );
+ navigateToNextStep();
+ return;
+ }
+
setErrorCode( error?.code || '' );
setErrorMessage( error.message );
setStatus( 'error' );
@@ -407,6 +428,8 @@ const TestAccountStep = () => {
retryCounter,
pollingPhase,
setJustCompletedStepId,
+ setSnackbar,
+ navigateToNextStep,
] );
const getPhaseMessage = ( phase: number ) => {
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-account/test/index.test.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-account/test/index.test.tsx
new file mode 100644
index 00000000000..0ed0f5a5cfd
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-account/test/index.test.tsx
@@ -0,0 +1,138 @@
+/**
+ * External dependencies
+ */
+import { render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import { useOnboardingContext } from '../../../data/onboarding-context';
+import TestAccountStep from '../index';
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+jest.mock( '../../../data/onboarding-context', () => ( {
+ useOnboardingContext: jest.fn(),
+} ) );
+
+jest.mock( '../../../components/header', () => ( {
+ __esModule: true,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- onClose is required by the component interface.
+ default: ( { onClose }: { onClose: () => void } ) => (
+ <div data-testid="step-header">Header</div>
+ ),
+} ) );
+
+jest.mock( '@woocommerce/onboarding', () => {
+ const MockLoader = ( { children }: { children: React.ReactNode } ) => (
+ <div data-testid="loader">{ children }</div>
+ );
+ MockLoader.Layout = ( { children }: { children: React.ReactNode } ) => (
+ <div>{ children }</div>
+ );
+ MockLoader.Illustration = ( {
+ children,
+ }: {
+ children: React.ReactNode;
+ } ) => <div>{ children }</div>;
+ MockLoader.Title = ( { children }: { children: React.ReactNode } ) => (
+ <div>{ children }</div>
+ );
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- progress is required by the component interface.
+ MockLoader.ProgressBar = ( { progress }: { progress: number } ) => (
+ <div data-testid="progress-bar" />
+ );
+ MockLoader.Sequence = ( { children }: { children: React.ReactNode } ) => (
+ <div>{ children }</div>
+ );
+ return { Loader: MockLoader };
+} );
+
+jest.mock( '@woocommerce/navigation', () => ( {
+ navigateTo: jest.fn(),
+ getNewPath: jest.fn( () => '' ),
+} ) );
+
+jest.mock( '~/settings-payments/utils', () => ( {
+ recordPaymentsOnboardingEvent: jest.fn(),
+} ) );
+
+jest.mock( '~/settings-payments/components/modals', () => ( {
+ WooPaymentsResetAccountModal: () => null,
+} ) );
+
+jest.mock( '~/utils/admin-settings', () => ( {
+ WC_ASSET_URL: '',
+} ) );
+
+const mockUseOnboardingContext = useOnboardingContext as jest.Mock;
+const mockApiFetch = apiFetch as jest.MockedFunction< typeof apiFetch >;
+
+const createMockContext = ( overrides: Record< string, unknown > = {} ) => ( {
+ currentStep: {
+ id: 'test_account',
+ status: 'not_started',
+ actions: {
+ init: { href: 'https://example.com/init' },
+ check: { href: 'https://example.com/check' },
+ },
+ },
+ closeModal: jest.fn(),
+ setJustCompletedStepId: jest.fn(),
+ sessionEntryPoint: 'settings',
+ setSnackbar: jest.fn(),
+ navigateToNextStep: jest.fn(),
+ ...overrides,
+} );
+
+describe( 'TestAccountStep', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'advances to the next step and shows a snackbar on a non-recoverable init error', async () => {
+ const context = createMockContext();
+ mockUseOnboardingContext.mockReturnValue( context );
+ mockApiFetch.mockRejectedValue( {
+ code: 'woocommerce_woopayments_onboarding_test_account_non_recoverable_error',
+ message:
+ 'A test account could not be created, but onboarding can continue without it.',
+ } );
+
+ render( <TestAccountStep /> );
+
+ await waitFor( () => {
+ expect( context.navigateToNextStep ).toHaveBeenCalled();
+ } );
+ expect( context.setSnackbar ).toHaveBeenCalledWith(
+ expect.objectContaining( { show: true } )
+ );
+ expect(
+ screen.queryByRole( 'button', { name: /try again/i } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows the error notice with a retry action for other init errors', async () => {
+ const context = createMockContext();
+ mockUseOnboardingContext.mockReturnValue( context );
+ mockApiFetch.mockRejectedValue( {
+ code: 'some_other_error',
+ message: 'Something went wrong.',
+ } );
+
+ render( <TestAccountStep /> );
+
+ expect(
+ await screen.findByText( 'Something went wrong.', {
+ selector: 'p',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', { name: /try again/i } )
+ ).toBeInTheDocument();
+ expect( context.navigateToNextStep ).not.toHaveBeenCalled();
+ expect( context.setSnackbar ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php
index 9bac46517d0..e423768242c 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php
@@ -67,6 +67,18 @@ class WooPaymentsService {
*/
const ONBOARDING_STEP_STATUS_BLOCKED = 'blocked';
+ /**
+ * Error identifiers for which retrying the test account initialization cannot succeed.
+ *
+ * Each entry is matched against both the error type and the error code reported by the
+ * WooPayments extension, since either field can carry the identifying value.
+ *
+ * These are errors the merchant has no way of fixing on their end (e.g. Stripe rejecting
+ * data that WooCommerce/WooPayments generates automatically), so instead of trapping the
+ * merchant in a retry loop, we skip the step forward and let onboarding proceed.
+ */
+ const ONBOARDING_TEST_ACCOUNT_NON_RECOVERABLE_ERROR_IDENTIFIERS = array( 'invalid_request_error' );
+
const ACTION_TYPE_REST = 'REST';
const ACTION_TYPE_REDIRECT = 'REDIRECT';
@@ -478,8 +490,10 @@ class WooPaymentsService {
/**
* Record an onboarding step as completed without re-checking whether a new onboarding action is allowed.
*
- * This is for internal use only, by callers that have already committed an account mutation and
- * just need to sync the resulting NOX onboarding state. Unlike mark_onboarding_step_completed(),
+ * This is for internal use only, by callers that just need to sync the NOX onboarding state
+ * with the outcome of an already-settled operation — whether it succeeded (e.g. a committed
+ * account mutation) or failed in a way that resolves the step (e.g. a non-recoverable
+ * initialization error that skips the step forward). Unlike mark_onboarding_step_completed(),
* it deliberately skips check_if_onboarding_step_action_is_acceptable() — including the shared
* onboarding lock check — because the state change is the consequence of work that already
* happened, not a new user action. Re-checking the lock here would let a concurrent request that
@@ -528,6 +542,117 @@ class WooPaymentsService {
return $result;
}
+ /**
+ * Determine if a test account initialization error is non-recoverable for the merchant.
+ *
+ * The WooPayments extension surfaces the underlying error details in the REST error response
+ * body, which Utils::rest_endpoint_post_request() stores as the WP_Error data.
+ *
+ * @param WP_Error $error The error returned by the test account initialization request.
+ *
+ * @return bool Whether the error is non-recoverable.
+ */
+ private function is_test_account_init_error_non_recoverable( WP_Error $error ): bool {
+ $error_data = $error->get_error_data();
+ if ( ! is_array( $error_data ) ) {
+ return false;
+ }
+
+ $error_type = $error_data['data']['error_type'] ?? $error_data['error_type'] ?? null;
+ $error_code = $error_data['data']['error_code'] ?? $error_data['error_code'] ?? null;
+
+ /**
+ * Filters the error identifiers for which retrying the WooPayments test account
+ * initialization cannot succeed.
+ *
+ * Each identifier is matched against both the error type and the error code reported
+ * by the WooPayments extension. When an initialization error matches, the test account
+ * onboarding step is skipped forward instead of being marked as failed.
+ *
+ * @param string[] $identifiers The non-recoverable error identifiers.
+ * @param WP_Error $error The error returned by the test account initialization request.
+ *
+ * @since 11.1.0
+ */
+ $non_recoverable_identifiers = (array) apply_filters(
+ 'woocommerce_woopayments_onboarding_test_account_non_recoverable_errors',
+ self::ONBOARDING_TEST_ACCOUNT_NON_RECOVERABLE_ERROR_IDENTIFIERS,
+ $error
+ );
+
+ return in_array( $error_type, $non_recoverable_identifiers, true ) ||
+ in_array( $error_code, $non_recoverable_identifiers, true );
+ }
+
+ /**
+ * Skip the test account onboarding step forward due to a non-recoverable failure.
+ *
+ * Marks the step completed (so onboarding can proceed), records an event, and throws
+ * a dedicated exception so the client can inform the merchant and advance.
+ *
+ * Uses the internal record method (not mark_onboarding_step_completed()) because the
+ * onboarding lock was already released and this state change is the consequence of the
+ * failed initialization, not a new user action.
+ *
+ * @param string $location The location for which we are onboarding.
+ * This is an ISO 3166-1 alpha-2 country code.
+ * @param string|null $source The source for the current onboarding flow.
+ * @param array $context Additional event context (e.g. reason, error code).
+ *
+ * @return void
+ * @throws ApiException Always. With a dedicated error code signaling the non-recoverable failure
+ * when the step completion was persisted, or with the regular client API
+ * error code when it wasn't — so the client offers a retry instead of
+ * advancing a merchant whose stored onboarding state didn't move.
+ */
+ private function skip_onboarding_test_account_step( string $location, ?string $source, array $context = array() ): void {
+ $step_recorded = $this->record_onboarding_step_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location, false, $source );
+ if ( ! $step_recorded ) {
+ // Leave a trail before falling back to the regular failure handling.
+ $this->proxy->call_function( 'wc_get_logger' )->error(
+ 'Failed to record the test account onboarding step as completed while skipping it forward.',
+ array(
+ 'source' => 'settings-payments',
+ 'location' => $location,
+ )
+ );
+
+ // Without the completed status persisted, advancing the client would desync it from
+ // the stored onboarding state. Fail the step instead: the client shows the regular
+ // error with a retry, and a later attempt can complete the skip.
+ $this->mark_onboarding_step_failed(
+ self::ONBOARDING_STEP_TEST_ACCOUNT,
+ $location,
+ array(
+ 'code' => 'skip_forward_not_persisted',
+ 'message' => esc_html__( 'Failed to record the onboarding progress.', 'woocommerce' ),
+ 'context' => $context,
+ )
+ );
+
+ throw new ApiException(
+ 'woocommerce_woopayments_onboarding_client_api_error',
+ esc_html__( 'Failed to initialize the test account.', 'woocommerce' ),
+ (int) WP_Http::FAILED_DEPENDENCY
+ );
+ }
+
+ $this->record_event(
+ self::EVENT_PREFIX . 'onboarding_test_account_skipped',
+ $location,
+ array_merge(
+ array( 'source' => $this->validate_onboarding_source( $source ) ),
+ $context
+ )
+ );
+
+ throw new ApiException(
+ 'woocommerce_woopayments_onboarding_test_account_non_recoverable_error',
+ esc_html__( 'A test account could not be created, but onboarding can continue without it.', 'woocommerce' ),
+ (int) WP_Http::FAILED_DEPENDENCY
+ );
+ }
+
/**
* Cleans an onboarding step progress.
*
@@ -1036,6 +1161,19 @@ class WooPaymentsService {
$this->clear_onboarding_lock();
if ( is_wp_error( $response ) ) {
+ // If the failure is non-recoverable from the merchant's perspective, don't trap
+ // them in a retry loop they can't win. Skip the step forward so onboarding can proceed.
+ if ( $this->is_test_account_init_error_non_recoverable( $response ) ) {
+ $this->skip_onboarding_test_account_step(
+ $location,
+ $source,
+ array(
+ 'reason' => 'non_recoverable_error',
+ 'error_code' => $response->get_error_code(),
+ )
+ );
+ }
+
// Mark the onboarding step as failed.
$this->mark_onboarding_step_failed(
self::ONBOARDING_STEP_TEST_ACCOUNT,
@@ -1055,7 +1193,7 @@ class WooPaymentsService {
);
}
- if ( ! is_array( $response ) || empty( $response['success'] ) ) {
+ if ( ! is_array( $response ) || ! array_key_exists( 'success', $response ) ) {
// Mark the onboarding step as failed.
$this->mark_onboarding_step_failed(
self::ONBOARDING_STEP_TEST_ACCOUNT,
@@ -1076,6 +1214,17 @@ class WooPaymentsService {
);
}
+ if ( empty( $response['success'] ) ) {
+ // The extension reported that a test account could not be created (e.g. the platform
+ // fell back to an account that requires verification). Retrying cannot succeed since
+ // an account exists by now. Skip the step forward so onboarding can proceed.
+ $this->skip_onboarding_test_account_step(
+ $location,
+ $source,
+ array( 'reason' => 'test_account_not_created' )
+ );
+ }
+
// Record an event for the test account being initialized.
$payment_methods_enabled = array();
$payment_methods_disabled = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php
index 1f22d615559..b5f8eddc3d4 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php
@@ -7229,6 +7229,319 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
$this->sut->onboarding_test_account_init( $location );
}
+ /**
+ * @testdox Test account init skips the step forward when the initialization error is non-recoverable.
+ *
+ * @dataProvider provider_onboarding_test_account_init_non_recoverable_error_data
+ *
+ * @param array $error_data The WP_Error data attached to the initialization error.
+ *
+ * @return void
+ */
+ public function test_onboarding_test_account_init_skips_step_on_non_recoverable_error( array $error_data ) {
+ $location = 'US';
+
+ // Arrange the WPCOM connection.
+ // Make it working.
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->any() )
+ ->method( 'is_connected' )
+ ->willReturn( true );
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->any() )
+ ->method( 'has_connected_owner' )
+ ->willReturn( true );
+
+ // Arrange the NOX profile.
+ $stored_profile = array();
+ $updated_stored_profiles = array();
+ $this->mockable_proxy->register_function_mocks(
+ array(
+ 'get_option' => function ( $option_name, $default_value = null ) use ( &$updated_stored_profiles, $stored_profile ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ return ! empty( $updated_stored_profiles ) ? end( $updated_stored_profiles ) : $stored_profile;
+ }
+
+ return $default_value;
+ },
+ 'update_option' => function ( $option_name, $value ) use ( &$updated_stored_profiles ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ $updated_stored_profiles[] = $value;
+ }
+
+ return true;
+ },
+ )
+ );
+
+ // Arrange the REST API request to fail with a non-recoverable error.
+ // The error shape mimics what Utils::rest_endpoint_post_request() produces when the
+ // WooPayments extension surfaces the underlying error details in the REST error response.
+ $this->mockable_proxy->register_static_mocks(
+ array(
+ Utils::class => array(
+ 'rest_endpoint_post_request' => function ( string $endpoint, array $params = array() ) use ( $error_data ) {
+ // Avoid parameter not used PHPCS errors.
+ unset( $params );
+ if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
+ return new WP_Error(
+ 'woocommerce_settings_payments_rest_error',
+ "REST request POST failed with: (bad_request) The statement descriptor matches a common term or website URL, and can't be used.",
+ $error_data
+ );
+ }
+
+ throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
+ },
+ ),
+ )
+ );
+
+ try {
+ $this->sut->onboarding_test_account_init( $location );
+ $this->fail( 'Expected ApiException was not thrown.' );
+ } catch ( ApiException $e ) {
+ $this->assertSame(
+ 'woocommerce_woopayments_onboarding_test_account_non_recoverable_error',
+ $e->getErrorCode(),
+ 'A dedicated error code should signal the non-recoverable failure to the client.'
+ );
+ }
+
+ $this->assertNotEmpty( $updated_stored_profiles );
+ $final_profile = end( $updated_stored_profiles );
+ $statuses = $final_profile['onboarding'][ $location ]['steps'][ WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT ]['statuses'];
+ $this->assertArrayHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED, $statuses, 'The step should be marked completed so onboarding can proceed.' );
+ $this->assertArrayNotHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED, $statuses, 'The step should not be marked failed.' );
+ }
+
+ /**
+ * Data provider for the non-recoverable initialization error shapes.
+ *
+ * The shapes mimic what Utils::rest_endpoint_post_request() produces, depending on where
+ * the WooPayments extension surfaces the error details in the REST error response.
+ *
+ * @return array<string, array<array>>
+ */
+ public function provider_onboarding_test_account_init_non_recoverable_error_data(): array {
+ return array(
+ 'error type and code in nested data' => array(
+ array(
+ 'code' => 'bad_request',
+ 'message' => "The statement descriptor matches a common term or website URL, and can't be used.",
+ 'data' => array(
+ 'status' => 400,
+ 'error_type' => 'invalid_request_error',
+ 'error_code' => 'invalid_request_error',
+ ),
+ ),
+ ),
+ 'error code only in nested data' => array(
+ array(
+ 'code' => 'bad_request',
+ 'message' => "The statement descriptor matches a common term or website URL, and can't be used.",
+ 'data' => array(
+ 'status' => 400,
+ 'error_code' => 'invalid_request_error',
+ ),
+ ),
+ ),
+ 'error type only in nested data' => array(
+ array(
+ 'code' => 'bad_request',
+ 'message' => "The statement descriptor matches a common term or website URL, and can't be used.",
+ 'data' => array(
+ 'status' => 400,
+ 'error_type' => 'invalid_request_error',
+ ),
+ ),
+ ),
+ 'error type at top level' => array(
+ array(
+ 'error_type' => 'invalid_request_error',
+ ),
+ ),
+ 'error code at top level' => array(
+ array(
+ 'error_code' => 'invalid_request_error',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @testdox Test account init treats filter-added error identifiers as non-recoverable.
+ *
+ * @return void
+ */
+ public function test_onboarding_test_account_init_non_recoverable_errors_are_filterable() {
+ $location = 'US';
+
+ // Arrange the WPCOM connection.
+ // Make it working.
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->any() )
+ ->method( 'is_connected' )
+ ->willReturn( true );
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->any() )
+ ->method( 'has_connected_owner' )
+ ->willReturn( true );
+
+ // Arrange the NOX profile.
+ $stored_profile = array();
+ $updated_stored_profiles = array();
+ $this->mockable_proxy->register_function_mocks(
+ array(
+ 'get_option' => function ( $option_name, $default_value = null ) use ( &$updated_stored_profiles, $stored_profile ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ return ! empty( $updated_stored_profiles ) ? end( $updated_stored_profiles ) : $stored_profile;
+ }
+
+ return $default_value;
+ },
+ 'update_option' => function ( $option_name, $value ) use ( &$updated_stored_profiles ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ $updated_stored_profiles[] = $value;
+ }
+
+ return true;
+ },
+ )
+ );
+
+ // Arrange the REST API request to fail with an error type that is only
+ // non-recoverable because the filter below adds it to the list.
+ $this->mockable_proxy->register_static_mocks(
+ array(
+ Utils::class => array(
+ 'rest_endpoint_post_request' => function ( string $endpoint, array $params = array() ) {
+ // Avoid parameter not used PHPCS errors.
+ unset( $params );
+ if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
+ return new WP_Error(
+ 'woocommerce_settings_payments_rest_error',
+ 'REST request POST failed with: (bad_request) Some custom platform failure.',
+ array(
+ 'code' => 'bad_request',
+ 'message' => 'Some custom platform failure.',
+ 'data' => array(
+ 'status' => 400,
+ 'error_type' => 'custom_blocker_error',
+ ),
+ )
+ );
+ }
+
+ throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
+ },
+ ),
+ )
+ );
+
+ add_filter(
+ 'woocommerce_woopayments_onboarding_test_account_non_recoverable_errors',
+ function ( array $identifiers ) {
+ $identifiers[] = 'custom_blocker_error';
+
+ return $identifiers;
+ }
+ );
+
+ try {
+ $this->sut->onboarding_test_account_init( $location );
+ $this->fail( 'Expected ApiException was not thrown.' );
+ } catch ( ApiException $e ) {
+ $this->assertSame(
+ 'woocommerce_woopayments_onboarding_test_account_non_recoverable_error',
+ $e->getErrorCode(),
+ 'The filter-added error identifier should be treated as non-recoverable.'
+ );
+ }
+
+ $this->assertNotEmpty( $updated_stored_profiles );
+ $final_profile = end( $updated_stored_profiles );
+ $statuses = $final_profile['onboarding'][ $location ]['steps'][ WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT ]['statuses'];
+ $this->assertArrayHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED, $statuses, 'The step should be marked completed so onboarding can proceed.' );
+ $this->assertArrayNotHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED, $statuses, 'The step should not be marked failed.' );
+ }
+
+ /**
+ * @testdox Test account init marks the step failed when the response is malformed.
+ *
+ * @return void
+ */
+ public function test_onboarding_test_account_init_throws_on_malformed_response() {
+ $location = 'US';
+
+ // Arrange the WPCOM connection.
+ // Make it working.
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->any() )
+ ->method( 'is_connected' )
+ ->willReturn( true );
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->any() )
+ ->method( 'has_connected_owner' )
+ ->willReturn( true );
+
+ // Arrange the NOX profile.
+ $stored_profile = array();
+ $updated_stored_profiles = array();
+ $this->mockable_proxy->register_function_mocks(
+ array(
+ 'get_option' => function ( $option_name, $default_value = null ) use ( &$updated_stored_profiles, $stored_profile ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ return ! empty( $updated_stored_profiles ) ? end( $updated_stored_profiles ) : $stored_profile;
+ }
+
+ return $default_value;
+ },
+ 'update_option' => function ( $option_name, $value ) use ( &$updated_stored_profiles ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ $updated_stored_profiles[] = $value;
+ }
+
+ return true;
+ },
+ )
+ );
+
+ // Arrange the REST API request to return a response without a `success` entry.
+ $this->mockable_proxy->register_static_mocks(
+ array(
+ Utils::class => array(
+ 'rest_endpoint_post_request' => function ( string $endpoint, array $params = array() ) {
+ // Avoid parameter not used PHPCS errors.
+ unset( $params );
+ if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
+ return array( 'status' => 'ok' );
+ }
+
+ throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
+ },
+ ),
+ )
+ );
+
+ try {
+ $this->sut->onboarding_test_account_init( $location );
+ $this->fail( 'Expected ApiException was not thrown.' );
+ } catch ( ApiException $e ) {
+ $this->assertSame(
+ 'woocommerce_woopayments_onboarding_client_api_error',
+ $e->getErrorCode(),
+ 'A malformed response should keep the existing failure behavior.'
+ );
+ }
+
+ $this->assertNotEmpty( $updated_stored_profiles );
+ $final_profile = end( $updated_stored_profiles );
+ $statuses = $final_profile['onboarding'][ $location ]['steps'][ WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT ]['statuses'];
+ $this->assertArrayHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED, $statuses, 'The step should be marked failed on a malformed response.' );
+ $this->assertArrayNotHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED, $statuses, 'The step should not be marked completed.' );
+ }
+
/**
* Test that error sanitization moves extra keys to context when storing a step error.
*
@@ -7780,11 +8093,11 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
}
/**
- * Test onboarding_test_account_init that throws an exception when the REST API call doesn't return success.
+ * @testdox Test account init skips the step forward when the extension reports the test account was not created.
*
* @return void
*/
- public function test_onboarding_test_account_init_throws_on_not_successful() {
+ public function test_onboarding_test_account_init_skips_step_when_test_account_not_created() {
$location = 'US';
// Arrange the WPCOM connection.
@@ -7799,28 +8112,20 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
->willReturn( true );
// Arrange the NOX profile.
- $step_id = WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT;
$stored_profile = array();
$updated_stored_profiles = array();
$this->mockable_proxy->register_function_mocks(
array(
- 'get_option' => function ( $option_name, $default_value = null ) use ( $stored_profile ) {
+ 'get_option' => function ( $option_name, $default_value = null ) use ( &$updated_stored_profiles, $stored_profile ) {
if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
- return $stored_profile;
+ return ! empty( $updated_stored_profiles ) ? end( $updated_stored_profiles ) : $stored_profile;
}
return $default_value;
},
- 'update_option' => function ( $option_name, $value ) use ( $stored_profile, &$updated_stored_profiles ) {
+ 'update_option' => function ( $option_name, $value ) use ( &$updated_stored_profiles ) {
if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
$updated_stored_profiles[] = $value;
-
- // Mimic the behavior of the original function.
- if ( $value === $stored_profile || maybe_serialize( $value ) === maybe_serialize( $stored_profile ) ) {
- return false;
- }
-
- return true;
}
return true;
@@ -7848,12 +8153,135 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
)
);
- // Assert.
- $this->expectException( \Exception::class );
- $this->expectExceptionMessage( esc_html__( 'Failed to initialize the test account.', 'woocommerce' ) );
+ try {
+ $this->sut->onboarding_test_account_init( $location );
+ $this->fail( 'Expected ApiException was not thrown.' );
+ } catch ( ApiException $e ) {
+ $this->assertSame(
+ 'woocommerce_woopayments_onboarding_test_account_non_recoverable_error',
+ $e->getErrorCode(),
+ 'A dedicated error code should signal the non-recoverable failure to the client.'
+ );
+ }
- // Act.
- $this->sut->onboarding_test_account_init( $location );
+ $this->assertCount( 1, $requests_made, 'The test account initialization request should be made exactly once.' );
+
+ $this->assertNotEmpty( $updated_stored_profiles );
+ $final_profile = end( $updated_stored_profiles );
+ $statuses = $final_profile['onboarding'][ $location ]['steps'][ WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT ]['statuses'];
+ $this->assertArrayHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED, $statuses, 'The step should be marked completed so onboarding can proceed.' );
+ $this->assertArrayNotHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED, $statuses, 'The step should not be marked failed.' );
+ }
+
+ /**
+ * @testdox Test account init falls back to the failure path when the skipped step completion fails to persist.
+ *
+ * @return void
+ */
+ public function test_onboarding_test_account_init_skip_step_falls_back_to_failure_when_completion_not_persisted() {
+ $location = 'US';
+
+ // Arrange the WPCOM connection.
+ // Make it working.
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->any() )
+ ->method( 'is_connected' )
+ ->willReturn( true );
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->any() )
+ ->method( 'has_connected_owner' )
+ ->willReturn( true );
+
+ // Arrange a logger that captures error calls.
+ $logged_errors = array();
+ $mock_logger = new class( $logged_errors ) {
+ /**
+ * The captured error calls.
+ *
+ * @var array
+ */
+ public $errors;
+
+ /**
+ * Constructor.
+ *
+ * @param array $errors The captured error calls, by reference.
+ */
+ public function __construct( array &$errors ) {
+ $this->errors = &$errors;
+ }
+
+ /**
+ * Capture an error log call.
+ *
+ * @param string $message The log message.
+ * @param array $context The log context.
+ *
+ * @return void
+ */
+ public function error( string $message, array $context = array() ) {
+ $this->errors[] = array(
+ 'message' => $message,
+ 'context' => $context,
+ );
+ }
+ };
+
+ // Arrange the NOX profile so that persisting the profile fails.
+ $this->mockable_proxy->register_function_mocks(
+ array(
+ 'get_option' => function ( $option_name, $default_value = null ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ return array();
+ }
+
+ return $default_value;
+ },
+ 'update_option' => function ( $option_name, $value ) {
+ // Avoid parameter not used PHPCS errors.
+ unset( $value );
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ return false;
+ }
+
+ return true;
+ },
+ 'wc_get_logger' => function () use ( $mock_logger ) {
+ return $mock_logger;
+ },
+ )
+ );
+
+ // Arrange the REST API request to report that the test account was not created.
+ $this->mockable_proxy->register_static_mocks(
+ array(
+ Utils::class => array(
+ 'rest_endpoint_post_request' => function ( string $endpoint, array $params = array() ) {
+ // Avoid parameter not used PHPCS errors.
+ unset( $params );
+ if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
+ return array( 'success' => false );
+ }
+
+ throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
+ },
+ ),
+ )
+ );
+
+ try {
+ $this->sut->onboarding_test_account_init( $location );
+ $this->fail( 'Expected ApiException was not thrown.' );
+ } catch ( ApiException $e ) {
+ $this->assertSame(
+ 'woocommerce_woopayments_onboarding_client_api_error',
+ $e->getErrorCode(),
+ 'Without the completion persisted, the client should see the regular failure (and retry) instead of being advanced.'
+ );
+ }
+
+ $this->assertNotEmpty( $logged_errors, 'A persistence failure of the skipped step completion should be logged.' );
+ $this->assertStringContainsString( 'Failed to record the test account onboarding step', $logged_errors[0]['message'] );
}
/**