Commit dce4cb771f5 for woocommerce
commit dce4cb771f54019f3e724998e916a17b62cb2f7d
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date: Tue Jul 14 15:37:08 2026 +0300
Fix NOX test account step reverting after being skipped forward (#66492)
* Fix skipped NOX test account step losing its completed status
When the test account initialization is skipped forward (non-recoverable
error or the platform reporting the account was not created), the step is
recorded as completed so onboarding can proceed. But the platform can fall
back to an account that requires verification: a connected test-drive
account that is not valid. get_onboarding_step_status() refused to honor
the stored completed status in exactly that state, and the auto-heal path
is bypassed because an account exists — so the step reverted to started/
not started, routing the merchant back into the step where re-initialization
dead-ends with test_account_already_exists.
Record a 'skipped' marker alongside the completed status (same statuses
array, single write, wiped together with it on step cleanup) and honor the
stored completed status for the test account step when the marker is
present, keeping the reported state consistent with the skip decision.
* Add changelog entry for NOX test account skip status fix
* Clear stale skip marker when the step completes through a non-skip path
The skip marker bypasses the test account step's invalid-account re-gating,
so it must only reflect the latest completion. Non-skip completions (the
valid-account auto-complete and disable_test_account()) previously hit the
early-return guard and left a stale marker behind, which would keep
bypassing the re-gate for a later genuine test account whose validity
lapses.
Re-write the stored statuses whenever the stored marker does not match how
the step is being completed: skip completions add the marker (including
over a previously marker-less completed status), non-skip completions
remove it.
* Gate the skip marker removal on a valid account vouching for completion
The generic step finish action completes any step through the non-skip
path with no account-validity check, and the client calls it on the test
account step from the test-or-live chooser. With the marker cleared
unconditionally on non-skip completions, that finish call would strip the
skip protection while the account is still the invalid fallback the skip
left behind, re-opening the re-gating trap.
Only remove the marker when a valid account vouches for the completion
being genuine; otherwise leave the stored statuses untouched. Also log a
warning when the auto-complete path fails to persist the completion, since
that write is no longer a guaranteed no-op, and pin the skip-over-skip
idempotency and completed-timestamp refresh behaviors with tests.
* Qualify the skip marker docblock about account-state vouching
* Add test for the auto-completion persistence warning
* Keep skip marker maintenance from masquerading as a new completion
When an already-completed step fell through the early return only to add
or clear the skip marker, record_onboarding_step_completed() also
rewrote the stored completion timestamp — despite overwrite being false
— and re-fired the onboarding_step_completed event, double-counting the
completion in telemetry.
Capture whether the step was already completed before the write and
gate both the timestamp refresh and the event on it, so marker-only
maintenance leaves the completion record untouched. The marker now
records its own timestamp (when the skip happened) instead of aliasing
the completion one.
* Skip the test account step forward for already-trapped merchants
The skip marker only helps merchants who skip forward after it shipped:
its sole writer is skip_onboarding_test_account_step(), and a merchant
already trapped by the pre-marker skip path (a connected test-drive
account that is not valid, with the step recorded completed but re-gated
away on read) could never reach it — onboarding_test_account_init()
threw test_account_already_exists before either skip call site. Being
trapped was the same predicate that blocked the cure, leaving a full
onboarding reset as the only way out.
Route that exact shape through the skip before the throw: retrying can
never succeed while an account is connected, so the error bought
nothing. The stored completed status is required so the
account-creation-in-flight state (connected, not valid, not completed)
keeps the existing behavior.
The marker-addition test now starts from the trapped state — account
connected before the request — instead of connecting mid-request, and
verifies the re-gate beforehand, that the platform is not called, and
that the stored completion timestamp is preserved.
* Replace the init-route heal with a read-time skip marker backfill
Live testing showed the trapped-merchant heal placed in
onboarding_test_account_init() never runs in the real flow: the REST
init handler marks the step started before calling the service, so a
trapped merchant carries {started, completed} and reports started — and
the client only calls init for not_started or failed steps. It sends
started steps into the polling loop, leaving the merchant on an endless
spinner with the heal unreachable.
Heal on read instead: when get_onboarding_step_status() finds a stored
completed status with no skip marker while a connected test account is
not valid, it backfills the marker (recording the same skipped
telemetry event with a dedicated reason) and reports completed. Every
status read heals, including the poll the trapped merchant is already
stuck in. This also covers a genuine completion whose account validity
lapses later — that shape dead-ends in the same spinner, since retrying
the initialization cannot succeed while an account is connected. A
legitimately in-flight account is unaffected because no completed
status is stored for it, and the auto-completion clears the backfilled
marker once the account becomes valid and working.
The trapped-merchant test now arranges the real shape ({started,
completed} plus a connected invalid account) and asserts the status
read heals it: marker backfilled, timestamps untouched, the platform
never called, and repeated reads idempotent. Status provider rows for
stored completed with an invalid test account now expect completed,
including over stale failed and blocked statuses.
* Extract a shared capturing logger test double
The anonymous logger class capturing calls into a by-reference array
was duplicated almost verbatim between the auto-completion warning test
and the skip-fallback error test, differing only in the captured method
name. Replace both with a single factory helper that captures every log
call with its level, letting the assertions pin the level explicitly.
* Soften the persistence-failure comments to match what retries can do
The comments around the completion and backfill persistence failures
called the retry-on-every-read behavior self-healing. That overstates
it: both paths write through the same update_option() chain, so if that
write keeps failing, retrying never heals anything — and escalating to
mark_onboarding_step_failed() would fail the same way. Describe the
retry without promising a recovery.
* Give the skip marker backfill its own method and telemetry event
The backfill reused the onboarding_test_account_skipped event with a
reason property, but its guard cannot distinguish a merchant trapped by
a pre-marker skip from one whose genuinely completed test account lost
validity later — a population that never skipped. Anyone counting the
skip event would silently include them, and a property does not
separate signals the way an event name does. Record a dedicated
onboarding_test_account_skip_marker_backfilled event instead, keeping
the skip event meaning what it says.
Also extract the ~35-line backfill block out of
get_onboarding_step_status() into
maybe_backfill_test_account_skip_marker(), moving the full rationale
into the method docblock.
---------
Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/fix-66486-nox-test-account-skip-status-regate b/plugins/woocommerce/changelog/fix-66486-nox-test-account-skip-status-regate
new file mode 100644
index 00000000000..994d7d3e293
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-66486-nox-test-account-skip-status-regate
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+NOX onboarding: keep reporting the test account step as completed after it was skipped forward, so merchants are not routed back into it when the platform fell back to an account that requires verification.
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 e423768242c..0b287154e1a 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,16 @@ class WooPaymentsService {
*/
const ONBOARDING_STEP_STATUS_BLOCKED = 'blocked';
+ /**
+ * Marker recorded alongside a step's completed status when the step was skipped forward
+ * rather than actually completed (e.g. the test account could not be created due to a
+ * non-recoverable failure). It is never reported as a step status; it only informs the
+ * status determination logic. Being stored with the step statuses, it shares their
+ * lifecycle: it is removed when the step progress is cleaned and when the step is
+ * completed through a non-skip path while a valid account vouches for the completion.
+ */
+ const ONBOARDING_STEP_SKIPPED_MARKER = 'skipped';
+
/**
* Error identifiers for which retrying the test account initialization cannot succeed.
*
@@ -266,7 +276,18 @@ class WooPaymentsService {
// The step has no reason to be blocked or failed.
$this->clear_onboarding_step_failed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );
$this->clear_onboarding_step_blocked( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );
- $this->mark_onboarding_step_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );
+ if ( ! $this->mark_onboarding_step_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location ) ) {
+ // Leave a trail. The completion (which also clears a possible stale skip
+ // marker) is retried on every status read, but if the underlying option
+ // write keeps failing, those retries won't succeed either.
+ $this->proxy->call_function( 'wc_get_logger' )->warning(
+ 'Failed to store the test account onboarding step completion while auto-completing it.',
+ array(
+ 'source' => 'settings-payments',
+ 'location' => $location,
+ )
+ );
+ }
return self::ONBOARDING_STEP_STATUS_COMPLETED;
}
@@ -290,12 +311,25 @@ class WooPaymentsService {
// Ignore any completed stored statuses because of the critical nature of the WPCOM connection.
break;
case self::ONBOARDING_STEP_TEST_ACCOUNT:
+ // Backfill the skip marker for merchants trapped with a marker-less completed
+ // status and an invalid test account. See the method for the full rationale.
+ if ( $meets_requirements ) {
+ $this->maybe_backfill_test_account_skip_marker( $location );
+ }
+
// If there is a stored completed status, we respect that IF there is NO invalid test account.
// This is the case when the user first creates a test account and then switches to live.
// The step can only be completed if the requirements are met.
+ // If the step was skipped forward (the test account could not be created), we respect the
+ // stored completed status even with an invalid test account present — that is precisely
+ // the state a skipped step can leave behind (the platform fell back to an account that
+ // requires verification), and re-gating it would route the merchant back to this step.
if ( $meets_requirements &&
$this->was_onboarding_step_marked_completed( $step_id, $location ) &&
- ! ( $this->has_test_account() && ! $this->has_valid_account() )
+ (
+ $this->was_onboarding_step_completed_via_skip( $step_id, $location ) ||
+ ! ( $this->has_test_account() && ! $this->has_valid_account() )
+ )
) {
return self::ONBOARDING_STEP_STATUS_COMPLETED;
}
@@ -467,6 +501,25 @@ class WooPaymentsService {
return ! empty( $statuses[ self::ONBOARDING_STEP_STATUS_COMPLETED ] );
}
+ /**
+ * Check if an onboarding step was marked as completed by being skipped forward.
+ *
+ * This means the step's completion does not reflect actual completion but a deliberate
+ * decision to let onboarding proceed despite the step's action not succeeding
+ * (e.g. a non-recoverable test account initialization failure).
+ *
+ * @param string $step_id The ID of the onboarding step.
+ * @param string $location The location for which we are onboarding.
+ * This is an ISO 3166-1 alpha-2 country code.
+ *
+ * @return bool Whether the onboarding step was completed by being skipped forward.
+ */
+ private function was_onboarding_step_completed_via_skip( string $step_id, string $location ): bool {
+ $statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );
+
+ return ! empty( $statuses[ self::ONBOARDING_STEP_SKIPPED_MARKER ] );
+ }
+
/**
* Mark an onboarding step as completed.
*
@@ -505,27 +558,58 @@ class WooPaymentsService {
* @param string $location The location for which we are onboarding.
* This is an ISO 3166-1 alpha-2 country code.
* @param bool $overwrite Whether to overwrite the step status if it is already completed and update the timestamp.
+ * Regardless of this, the stored statuses are re-written whenever the
+ * skip marker needs to be added or can be safely removed, so the marker
+ * reflects how the latest completion happened — to the extent the
+ * account state can vouch for it. Such marker-only maintenance leaves
+ * the stored completion timestamp untouched and does not re-record
+ * the step completion event.
* @param string|null $source Optional. The source for the current onboarding flow.
* If not provided, it will identify the source as the WC Admin Payments settings.
+ * @param bool $skipped Optional. Whether the step is being completed by skipping it forward
+ * rather than through actual completion. A marker is stored with the
+ * step statuses so the status determination logic can tell the two apart.
+ * A non-skip completion clears a previously stored marker, but only
+ * when a valid account vouches for the completion being genuine.
*
* @return bool Whether the onboarding step was marked as completed.
*/
- private function record_onboarding_step_completed( string $step_id, string $location, bool $overwrite = false, ?string $source = self::SESSION_ENTRY_DEFAULT ): bool {
+ private function record_onboarding_step_completed( string $step_id, string $location, bool $overwrite = false, ?string $source = self::SESSION_ENTRY_DEFAULT, bool $skipped = false ): bool {
// Clear possible failed status for the step.
$this->clear_onboarding_step_failed( $step_id, $location );
- $statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );
- if ( ! $overwrite && ! empty( $statuses[ self::ONBOARDING_STEP_STATUS_COMPLETED ] ) ) {
+ $statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );
+ $was_completed = ! empty( $statuses[ self::ONBOARDING_STEP_STATUS_COMPLETED ] );
+ $has_marker = ! empty( $statuses[ self::ONBOARDING_STEP_SKIPPED_MARKER ] );
+ // The marker must not outlive the skip being the reason for the completion. Otherwise,
+ // a stale marker would keep bypassing the status re-gating (e.g. for a genuine test
+ // account whose validity lapses later). But it can only be removed when the account state
+ // vouches for a genuine completion — generic completion paths (e.g. the step finish action)
+ // must not strip the skip protection while the account is still the invalid fallback
+ // the skip left behind.
+ $add_marker = $skipped && ! $has_marker;
+ $clear_marker = ! $skipped && $has_marker && $this->has_valid_account();
+ if ( ! $overwrite && $was_completed && ! $add_marker && ! $clear_marker ) {
return true;
}
- // Mark the step as completed and record the timestamp.
- $statuses[ self::ONBOARDING_STEP_STATUS_COMPLETED ] = $this->proxy->call_function( 'time' );
+ // Mark the step as completed and record the timestamp. An already-completed step keeps
+ // its stored timestamp (honoring $overwrite): falling through the early return for
+ // marker maintenance must not masquerade as a new completion.
+ if ( $overwrite || ! $was_completed ) {
+ $statuses[ self::ONBOARDING_STEP_STATUS_COMPLETED ] = $this->proxy->call_function( 'time' );
+ }
+ if ( $skipped ) {
+ // The marker records when the skip happened.
+ $statuses[ self::ONBOARDING_STEP_SKIPPED_MARKER ] = $this->proxy->call_function( 'time' );
+ } elseif ( $clear_marker ) {
+ unset( $statuses[ self::ONBOARDING_STEP_SKIPPED_MARKER ] );
+ }
// Store the updated step data.
$result = $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses', $statuses );
- if ( $result ) {
+ if ( $result && ( $overwrite || ! $was_completed ) ) {
$source = $this->validate_onboarding_source( $source );
// Record an event for the step being completed.
@@ -606,7 +690,7 @@ class WooPaymentsService {
* 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 );
+ $step_recorded = $this->record_onboarding_step_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location, false, $source, true );
if ( ! $step_recorded ) {
// Leave a trail before falling back to the regular failure handling.
$this->proxy->call_function( 'wc_get_logger' )->error(
@@ -653,6 +737,61 @@ class WooPaymentsService {
);
}
+ /**
+ * Backfill the skip marker for a test account step stuck in an unrecoverable state.
+ *
+ * A stored completed status with no skip marker while a connected test account is not valid
+ * is a state the merchant cannot recover from on their own: the status determination would
+ * re-derive the step as not completed and the client would route the merchant back into it,
+ * but re-initialization can never succeed while an account is connected — they would end up
+ * polling indefinitely. This is the state a skip-forward from before the skip marker existed
+ * left behind (the platform fell back to an account that requires verification), and also
+ * where a genuine completion lands if the account validity lapses later. Backfilling the
+ * marker lets the stored completed status be honored so onboarding can move on.
+ *
+ * A legitimately in-flight account is not affected because no completed status is stored for
+ * it, and if the account later becomes valid and working, the auto-completion clears the
+ * backfilled marker again.
+ *
+ * @param string $location The location for which we are onboarding.
+ * This is an ISO 3166-1 alpha-2 country code.
+ *
+ * @return void
+ */
+ private function maybe_backfill_test_account_skip_marker( string $location ): void {
+ if ( ! $this->was_onboarding_step_marked_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location ) ||
+ $this->was_onboarding_step_completed_via_skip( self::ONBOARDING_STEP_TEST_ACCOUNT, $location ) ||
+ ! $this->has_test_account() || $this->has_valid_account()
+ ) {
+ return;
+ }
+
+ if ( ! $this->record_onboarding_step_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location, false, self::SESSION_ENTRY_DEFAULT, true ) ) {
+ // Leave a trail. The backfill is retried on every status read, but if the underlying
+ // option write keeps failing, those retries won't succeed either.
+ $this->proxy->call_function( 'wc_get_logger' )->warning(
+ 'Failed to store the test account onboarding step skip marker while backfilling it.',
+ array(
+ 'source' => 'settings-payments',
+ 'location' => $location,
+ )
+ );
+
+ return;
+ }
+
+ // Record a dedicated event so the backfill can be told apart from a regular skip-forward:
+ // this state is also reachable by a genuine completion whose account validity lapsed
+ // later, i.e. a merchant who never skipped.
+ $this->record_event(
+ self::EVENT_PREFIX . 'onboarding_test_account_skip_marker_backfilled',
+ $location,
+ array(
+ 'source' => self::SESSION_ENTRY_DEFAULT,
+ )
+ );
+ }
+
/**
* Cleans an onboarding step progress.
*
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 b5f8eddc3d4..e9b0e8cf067 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
@@ -3825,9 +3825,11 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
'test_account' => true,
),
),
+ // A marker-less completed status with an invalid test account gets the skip marker
+ // backfilled on read, so the step reports completed instead of trapping the merchant.
'test_account - stored started and completed with invalid test account, met requirements' => array(
WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT,
- WooPaymentsService::ONBOARDING_STEP_STATUS_STARTED,
+ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED,
array(
WooPaymentsService::ONBOARDING_STEP_STATUS_STARTED => $current_time - 10,
WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED => $current_time,
@@ -3842,9 +3844,11 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
'test_account' => true,
),
),
+ // The backfilled skip marker takes precedence over stale failed statuses too — a trapped
+ // merchant cannot fix the test account, so surfacing the failure would still dead-end them.
'test_account - stored started, failed, and completed with invalid test account, met requirements' => array(
WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT,
- WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED,
+ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED,
array(
WooPaymentsService::ONBOARDING_STEP_STATUS_STARTED => $current_time - 10,
WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED => $current_time - 5,
@@ -3860,9 +3864,11 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
'test_account' => true,
),
),
+ // Same for stale blocked statuses: resolving a blocker cannot make the invalid test
+ // account recoverable, so the backfilled completion still wins.
'test_account - stored started, blocked, and completed with invalid test account, met requirements' => array(
WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT,
- WooPaymentsService::ONBOARDING_STEP_STATUS_BLOCKED,
+ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED,
array(
WooPaymentsService::ONBOARDING_STEP_STATUS_STARTED => $current_time - 10,
WooPaymentsService::ONBOARDING_STEP_STATUS_BLOCKED => $current_time - 5,
@@ -8174,11 +8180,11 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
}
/**
- * @testdox Test account init falls back to the failure path when the skipped step completion fails to persist.
+ * @testdox Test account step keeps reporting completed after being skipped forward even if an invalid test account exists.
*
* @return void
*/
- public function test_onboarding_test_account_init_skip_step_falls_back_to_failure_when_completion_not_persisted() {
+ public function test_onboarding_test_account_step_reports_completed_after_skip_despite_invalid_test_account() {
$location = 'US';
// Arrange the WPCOM connection.
@@ -8192,75 +8198,69 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
->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.
+ // 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 ) {
+ 'get_option' => function ( $option_name, $default_value = null ) use ( &$updated_stored_profiles, $stored_profile ) {
if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
- return array();
+ return ! empty( $updated_stored_profiles ) ? end( $updated_stored_profiles ) : $stored_profile;
}
return $default_value;
},
- 'update_option' => function ( $option_name, $value ) {
- // Avoid parameter not used PHPCS errors.
- unset( $value );
+ 'update_option' => function ( $option_name, $value ) use ( &$updated_stored_profiles ) {
if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
- return false;
+ $updated_stored_profiles[] = $value;
}
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.
+ // Arrange the account state: no account before the initialization, and a test-drive
+ // account that requires verification (i.e. is not valid) after the platform fell back to it.
+ $account_connected = false;
+ $this->mock_provider
+ ->expects( $this->any() )
+ ->method( 'is_account_connected' )
+ ->willReturnCallback(
+ function () use ( &$account_connected ) {
+ return $account_connected;
+ }
+ );
+ $this->mock_account_service
+ ->expects( $this->any() )
+ ->method( 'is_stripe_account_valid' )
+ ->willReturn( false );
+ $this->mock_account_service
+ ->expects( $this->any() )
+ ->method( 'get_account_status_data' )
+ ->willReturn(
+ array(
+ 'status' => 'restricted',
+ 'testDrive' => true,
+ 'isLive' => false,
+ 'paymentsEnabled' => false,
+ 'detailsSubmitted' => false,
+ )
+ );
+
+ // Arrange the REST API requests.
$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.
+ 'rest_endpoint_post_request' => function ( string $endpoint, array $params = array() ) use ( &$account_connected ) {
unset( $params );
if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
- return array( 'success' => false );
+ // The platform fell back to an account that requires verification.
+ $account_connected = true;
+
+ return array(
+ 'success' => false,
+ );
}
throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
@@ -8274,23 +8274,30 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
$this->fail( 'Expected ApiException was not thrown.' );
} catch ( ApiException $e ) {
$this->assertSame(
- 'woocommerce_woopayments_onboarding_client_api_error',
+ 'woocommerce_woopayments_onboarding_test_account_non_recoverable_error',
$e->getErrorCode(),
- 'Without the completion persisted, the client should see the regular failure (and retry) instead of being advanced.'
+ 'A dedicated error code should signal the non-recoverable failure to the client.'
);
}
- $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'] );
+ $status = $this->sut->get_onboarding_step_status( WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT, $location );
+ $this->assertSame(
+ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED,
+ $status,
+ 'The skipped step should keep reporting completed so the merchant is not routed back to it.'
+ );
}
/**
- * Test that onboarding lock mechanism works during test account initialization.
+ * @testdox Test account step status backfills the skip marker for a merchant trapped with a marker-less completed step and an invalid test account.
+ *
+ * @return void
*/
- public function test_onboarding_lock_mechanism_during_test_account_init(): void {
+ public function test_onboarding_test_account_status_backfills_skip_marker_for_trapped_merchant() {
$location = 'US';
// Arrange the WPCOM connection.
+ // Make it working.
$this->mock_wpcom_connection_manager
->expects( $this->any() )
->method( 'is_connected' )
@@ -8300,141 +8307,128 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
->method( 'has_connected_owner' )
->willReturn( true );
- // Track onboarding lock state changes.
- $lock_states = array();
- $current_time = $this->current_time;
-
- // Arrange the NOX profile and lock tracking.
+ // Arrange the NOX profile with the exact state a real trapped merchant carries: the REST
+ // init handler marked the step started, the skip-forward from before the marker existed
+ // recorded completed, and there is no skip marker.
+ $stored_profile = array(
+ 'onboarding' => array(
+ $location => array(
+ 'steps' => array(
+ WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT => array(
+ 'statuses' => array(
+ WooPaymentsService::ONBOARDING_STEP_STATUS_STARTED => $this->current_time - 200,
+ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED => $this->current_time - 100,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ $updated_stored_profiles = array();
$this->mockable_proxy->register_function_mocks(
array(
- 'get_option' => function ( $option_name, $default_value = null ) use ( &$lock_states ) {
+ 'get_option' => function ( $option_name, $default_value = null ) use ( &$updated_stored_profiles, $stored_profile ) {
if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
- return array();
- }
- if ( WooPaymentsService::NOX_ONBOARDING_LOCKED_KEY === $option_name ) {
- return end( $lock_states ) !== false ? end( $lock_states ) : $default_value;
+ return ! empty( $updated_stored_profiles ) ? end( $updated_stored_profiles ) : $stored_profile;
}
+
return $default_value;
},
- 'update_option' => function ( $option_name, $value ) use ( &$lock_states, $current_time ) {
- if ( WooPaymentsService::NOX_ONBOARDING_LOCKED_KEY === $option_name ) {
- $lock_states[] = $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;
},
)
);
- // Mock successful REST API response.
- $expected_response = array(
- 'success' => true,
- );
- $this->mockable_proxy->register_static_mocks(
- array(
- Utils::class => array(
- 'rest_endpoint_post_request' => function ( string $endpoint ) use ( $expected_response ) {
- if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
- return $expected_response;
- }
- throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
- },
- ),
- )
- );
-
- // Act - call the method that should set and clear the lock.
- $result = $this->sut->onboarding_test_account_init( $location );
-
- // Assert that the result is successful.
- $this->assertEquals( $expected_response, $result );
-
- // Assert that the lock was set and then cleared.
- $this->assertCount( 2, $lock_states, 'Expected exactly 2 lock state changes: set lock (timestamp) and clear lock (0)' );
- $this->assertSame( $current_time, $lock_states[0], 'First lock state should be current timestamp' );
- $this->assertSame( 0, $lock_states[1], 'Second lock state should be 0 (cleared)' );
- }
-
- /**
- * Test that onboarding lock is cleared even when an exception occurs.
- */
- public function test_onboarding_lock_cleared_on_exception(): void {
- $location = 'US';
-
- // Arrange the WPCOM connection.
- $this->mock_wpcom_connection_manager
+ // Arrange the trapped account state: the platform fell back to a test-drive account
+ // that requires verification (i.e. is not valid).
+ $this->mock_provider
->expects( $this->any() )
- ->method( 'is_connected' )
+ ->method( 'is_account_connected' )
->willReturn( true );
- $this->mock_wpcom_connection_manager
+ $this->mock_account_service
->expects( $this->any() )
- ->method( 'has_connected_owner' )
- ->willReturn( true );
-
- // Track onboarding lock state changes.
- $lock_states = array();
- $current_time = $this->current_time;
-
- // Arrange the NOX profile and lock tracking.
- $this->mockable_proxy->register_function_mocks(
- array(
- 'get_option' => function ( $option_name, $default_value = null ) use ( &$lock_states ) {
- if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
- return array();
- }
- if ( WooPaymentsService::NOX_ONBOARDING_LOCKED_KEY === $option_name ) {
- return end( $lock_states ) !== false ? end( $lock_states ) : $default_value;
- }
- return $default_value;
- },
- 'update_option' => function ( $option_name, $value ) use ( &$lock_states, $current_time ) {
- if ( WooPaymentsService::NOX_ONBOARDING_LOCKED_KEY === $option_name ) {
- $lock_states[] = $value;
- }
- return true;
- },
- )
- );
+ ->method( 'is_stripe_account_valid' )
+ ->willReturn( false );
+ $this->mock_account_service
+ ->expects( $this->any() )
+ ->method( 'get_account_status_data' )
+ ->willReturn(
+ array(
+ 'status' => 'restricted',
+ 'testDrive' => true,
+ 'isLive' => false,
+ 'paymentsEnabled' => false,
+ 'detailsSubmitted' => false,
+ )
+ );
- // Mock REST API to throw an exception.
+ // Arrange the REST API requests: determining the step status must not call the platform.
$this->mockable_proxy->register_static_mocks(
array(
Utils::class => array(
- 'rest_endpoint_post_request' => function ( string $endpoint ) {
- if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
- throw new \Exception( 'Simulated API failure' );
- }
- throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
+ 'rest_endpoint_post_request' => function ( string $endpoint, array $params = array() ) {
+ unset( $params );
+ throw new \Exception( esc_html( 'The platform should not be called: ' . $endpoint ) );
},
),
)
);
- // Act & Assert - expect the method to handle the exception and still clear the lock.
- try {
- $this->sut->onboarding_test_account_init( $location );
+ // The status read should backfill the skip marker and report completed, instead of
+ // re-deriving started and routing the merchant back into the step.
+ $status = $this->sut->get_onboarding_step_status( WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT, $location );
+ $this->assertSame(
+ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED,
+ $status,
+ 'The trapped merchant should report completed so onboarding can move on.'
+ );
- $this->fail( 'Expected an exception to be re-thrown as WP_Error' );
- } catch ( \Throwable $e ) {
- // The exception handling should have cleared the lock even though an error occurred.
- $this->assertInstanceOf( \Exception::class, $e );
- }
+ $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_SKIPPED_MARKER, $statuses, 'The skip marker should be backfilled for a step completed without one.' );
+ $this->assertSame(
+ $this->current_time - 100,
+ $statuses[ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED ],
+ 'The stored completion timestamp should be preserved — the backfill is not a new completion.'
+ );
+ $this->assertSame(
+ $this->current_time - 200,
+ $statuses[ WooPaymentsService::ONBOARDING_STEP_STATUS_STARTED ],
+ 'The stored started timestamp should be left untouched.'
+ );
- // Assert that the lock was set and then cleared despite the exception.
- $this->assertCount( 2, $lock_states, 'Expected exactly 2 lock state changes even with exception: set lock (timestamp) and clear lock (0)' );
- $this->assertSame( $current_time, $lock_states[0], 'First lock state should be current timestamp' );
- $this->assertSame( 0, $lock_states[1], 'Second lock state should be 0 (cleared) even after exception' );
+ // Subsequent reads are idempotent: still completed, no further writes.
+ $writes_after_backfill = count( $updated_stored_profiles );
+
+ $status = $this->sut->get_onboarding_step_status( WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT, $location );
+ $this->assertSame(
+ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED,
+ $status,
+ 'The healed step should keep reporting completed.'
+ );
+ $this->assertCount(
+ $writes_after_backfill,
+ $updated_stored_profiles,
+ 'A repeated status read should not write again.'
+ );
}
/**
- * Test onboarding_test_account_init.
+ * @testdox A stale skip marker is cleared when the test account step completes through a non-skip path.
*
* @return void
*/
- public function test_onboarding_test_account_init() {
+ public function test_onboarding_test_account_non_skip_completion_clears_stale_skip_marker() {
$location = 'US';
// Arrange the WPCOM connection.
- // Make it working since it is a dependency for the step.
+ // Make it working.
$this->mock_wpcom_connection_manager
->expects( $this->any() )
->method( 'is_connected' )
@@ -8444,16 +8438,15 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
->method( 'has_connected_owner' )
->willReturn( true );
- // Arrange the NOX profile.
- $step_id = WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT;
- $started_timestamp = $this->current_time - 100;
+ // Arrange the NOX profile with a step completed by being skipped forward.
$stored_profile = array(
'onboarding' => array(
$location => array(
'steps' => array(
- $step_id => array(
+ WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT => array(
'statuses' => array(
- WooPaymentsService::ONBOARDING_STEP_STATUS_STARTED => $started_timestamp,
+ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED => $this->current_time - 100,
+ WooPaymentsService::ONBOARDING_STEP_SKIPPED_MARKER => $this->current_time - 100,
),
),
),
@@ -8463,24 +8456,16 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
$updated_stored_profiles = array();
$this->mockable_proxy->register_function_mocks(
array(
- 'get_option' => function ( $option_name, $default_value = null ) use ( $stored_profile, &$updated_stored_profiles ) {
+ 'get_option' => function ( $option_name, $default_value = null ) use ( &$updated_stored_profiles, $stored_profile ) {
if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
- // Chain the responses to simulate the sequence of DB updates.
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;
@@ -8488,7 +8473,638 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
)
);
- // Arrange the REST API requests.
+ // Arrange a connected test-drive account that becomes valid and working (the merchant
+ // eventually got a genuine test account), then lapses back to invalid.
+ $account_valid = true;
+ $this->mock_provider
+ ->expects( $this->any() )
+ ->method( 'is_account_connected' )
+ ->willReturn( true );
+ $this->mock_account_service
+ ->expects( $this->any() )
+ ->method( 'is_stripe_account_valid' )
+ ->willReturnCallback(
+ function () use ( &$account_valid ) {
+ return $account_valid;
+ }
+ );
+ $this->mock_account_service
+ ->expects( $this->any() )
+ ->method( 'get_account_status_data' )
+ ->willReturnCallback(
+ function () use ( &$account_valid ) {
+ return array(
+ 'status' => $account_valid ? 'complete' : 'restricted',
+ 'testDrive' => true,
+ 'isLive' => false,
+ 'paymentsEnabled' => $account_valid,
+ 'detailsSubmitted' => $account_valid,
+ );
+ }
+ );
+
+ // The valid, working test account auto-completes the step through the non-skip path,
+ // which should clear the stale skip marker.
+ $status = $this->sut->get_onboarding_step_status( WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT, $location );
+ $this->assertSame( WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED, $status );
+
+ $this->assertNotEmpty( $updated_stored_profiles );
+ $final_profile = end( $updated_stored_profiles );
+ $statuses = $final_profile['onboarding'][ $location ]['steps'][ WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT ]['statuses'];
+ $this->assertArrayNotHasKey( WooPaymentsService::ONBOARDING_STEP_SKIPPED_MARKER, $statuses, 'A non-skip completion should clear a stale skip marker.' );
+ $this->assertSame( $this->current_time - 100, $statuses[ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED ], 'The stored completion timestamp should be preserved — marker maintenance is not a new completion.' );
+
+ // If the account validity lapses again, the merchant is in a state they cannot recover
+ // from on their own (retrying the initialization cannot succeed with an account connected),
+ // so the skip marker is backfilled on read and the step keeps reporting completed.
+ $account_valid = false;
+
+ $status = $this->sut->get_onboarding_step_status( WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT, $location );
+ $this->assertSame(
+ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED,
+ $status,
+ 'A lapsed account validity should get the skip marker backfilled instead of trapping the merchant.'
+ );
+
+ $final_profile = end( $updated_stored_profiles );
+ $statuses = $final_profile['onboarding'][ $location ]['steps'][ WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT ]['statuses'];
+ $this->assertArrayHasKey( WooPaymentsService::ONBOARDING_STEP_SKIPPED_MARKER, $statuses, 'The skip marker should be backfilled when the account validity lapses.' );
+ }
+
+ /**
+ * @testdox A warning is logged when the auto-completion of the test account step fails to persist.
+ *
+ * @return void
+ */
+ public function test_onboarding_test_account_auto_completion_logs_warning_when_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 log calls.
+ $logged_calls = array();
+ $mock_logger = $this->mock_capturing_logger( $logged_calls );
+
+ // Arrange the NOX profile with a step completed by being skipped forward,
+ // but make persisting the profile fail.
+ $stored_profile = array(
+ 'onboarding' => array(
+ $location => array(
+ 'steps' => array(
+ WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT => array(
+ 'statuses' => array(
+ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED => $this->current_time - 100,
+ WooPaymentsService::ONBOARDING_STEP_SKIPPED_MARKER => $this->current_time - 100,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ $this->mockable_proxy->register_function_mocks(
+ array(
+ 'get_option' => function ( $option_name, $default_value = null ) use ( $stored_profile ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ return $stored_profile;
+ }
+
+ 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 a connected, valid, and working test-drive account so the step auto-completes
+ // through the non-skip path (which also tries to clear the stale skip marker).
+ $this->mock_provider
+ ->expects( $this->any() )
+ ->method( 'is_account_connected' )
+ ->willReturn( true );
+ $this->mock_account_service
+ ->expects( $this->any() )
+ ->method( 'is_stripe_account_valid' )
+ ->willReturn( true );
+ $this->mock_account_service
+ ->expects( $this->any() )
+ ->method( 'get_account_status_data' )
+ ->willReturn(
+ array(
+ 'status' => 'complete',
+ 'testDrive' => true,
+ 'isLive' => false,
+ 'paymentsEnabled' => true,
+ 'detailsSubmitted' => true,
+ )
+ );
+
+ $status = $this->sut->get_onboarding_step_status( WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT, $location );
+
+ $this->assertSame(
+ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED,
+ $status,
+ 'The step should still report completed since the auto-completion is retried on every status read.'
+ );
+ $this->assertNotEmpty( $logged_calls, 'A persistence failure of the auto-completion should be logged.' );
+ $this->assertSame( 'warning', $logged_calls[0]['level'] );
+ $this->assertStringContainsString( 'Failed to store the test account onboarding step completion', $logged_calls[0]['message'] );
+ $this->assertSame( 'settings-payments', $logged_calls[0]['context']['source'] );
+ }
+
+ /**
+ * @testdox Finishing the test account step preserves the skip marker while the fallback account is not valid.
+ *
+ * @return void
+ */
+ public function test_onboarding_test_account_finish_preserves_skip_marker_with_invalid_account() {
+ $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 with a step completed by being skipped forward.
+ $stored_profile = array(
+ 'onboarding' => array(
+ $location => array(
+ 'steps' => array(
+ WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT => array(
+ 'statuses' => array(
+ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED => $this->current_time - 100,
+ WooPaymentsService::ONBOARDING_STEP_SKIPPED_MARKER => $this->current_time - 100,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ $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 invalid fallback test-drive account the skip left behind.
+ $this->mock_provider
+ ->expects( $this->any() )
+ ->method( 'is_account_connected' )
+ ->willReturn( true );
+ $this->mock_account_service
+ ->expects( $this->any() )
+ ->method( 'is_stripe_account_valid' )
+ ->willReturn( false );
+ $this->mock_account_service
+ ->expects( $this->any() )
+ ->method( 'get_account_status_data' )
+ ->willReturn(
+ array(
+ 'status' => 'restricted',
+ 'testDrive' => true,
+ 'isLive' => false,
+ 'paymentsEnabled' => false,
+ 'detailsSubmitted' => false,
+ )
+ );
+
+ // This is what the generic step finish action does.
+ $result = $this->sut->mark_onboarding_step_completed( WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT, $location );
+ $this->assertTrue( $result );
+
+ $this->assertEmpty( $updated_stored_profiles, 'The stored statuses should not be re-written while the account cannot vouch for a genuine completion.' );
+
+ $status = $this->sut->get_onboarding_step_status( WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT, $location );
+ $this->assertSame(
+ WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED,
+ $status,
+ 'The skip protection should survive a finish action on the already-skipped step.'
+ );
+ }
+
+ /**
+ * @testdox Skipping the test account step again does not re-write the already skipped step statuses.
+ *
+ * @return void
+ */
+ public function test_onboarding_test_account_skip_over_skip_is_idempotent() {
+ $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;
+ },
+ )
+ );
+
+ // No account is ever created: the initialization fails with a non-recoverable error each time.
+ $this->mock_provider
+ ->expects( $this->any() )
+ ->method( 'is_account_connected' )
+ ->willReturn( false );
+
+ // Arrange the REST API requests.
+ $this->mockable_proxy->register_static_mocks(
+ array(
+ Utils::class => array(
+ 'rest_endpoint_post_request' => function ( string $endpoint, array $params = array() ) {
+ 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.",
+ 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',
+ ),
+ )
+ );
+ }
+
+ 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() );
+ }
+
+ $writes_after_first_skip = count( $updated_stored_profiles );
+ $this->assertGreaterThan( 0, $writes_after_first_skip );
+
+ 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() );
+ }
+
+ $this->assertCount( $writes_after_first_skip, $updated_stored_profiles, 'A repeated skip should not re-write the already skipped step statuses.' );
+
+ $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 );
+ $this->assertArrayHasKey( WooPaymentsService::ONBOARDING_STEP_SKIPPED_MARKER, $statuses );
+ }
+
+ /**
+ * @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 log calls.
+ $logged_calls = array();
+ $mock_logger = $this->mock_capturing_logger( $logged_calls );
+
+ // 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_calls, 'A persistence failure of the skipped step completion should be logged.' );
+ $this->assertSame( 'error', $logged_calls[0]['level'] );
+ $this->assertStringContainsString( 'Failed to record the test account onboarding step', $logged_calls[0]['message'] );
+ }
+
+ /**
+ * Test that onboarding lock mechanism works during test account initialization.
+ */
+ public function test_onboarding_lock_mechanism_during_test_account_init(): void {
+ $location = 'US';
+
+ // Arrange the WPCOM connection.
+ $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 );
+
+ // Track onboarding lock state changes.
+ $lock_states = array();
+ $current_time = $this->current_time;
+
+ // Arrange the NOX profile and lock tracking.
+ $this->mockable_proxy->register_function_mocks(
+ array(
+ 'get_option' => function ( $option_name, $default_value = null ) use ( &$lock_states ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ return array();
+ }
+ if ( WooPaymentsService::NOX_ONBOARDING_LOCKED_KEY === $option_name ) {
+ return end( $lock_states ) !== false ? end( $lock_states ) : $default_value;
+ }
+ return $default_value;
+ },
+ 'update_option' => function ( $option_name, $value ) use ( &$lock_states, $current_time ) {
+ if ( WooPaymentsService::NOX_ONBOARDING_LOCKED_KEY === $option_name ) {
+ $lock_states[] = $value;
+ }
+ return true;
+ },
+ )
+ );
+
+ // Mock successful REST API response.
+ $expected_response = array(
+ 'success' => true,
+ );
+ $this->mockable_proxy->register_static_mocks(
+ array(
+ Utils::class => array(
+ 'rest_endpoint_post_request' => function ( string $endpoint ) use ( $expected_response ) {
+ if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
+ return $expected_response;
+ }
+ throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
+ },
+ ),
+ )
+ );
+
+ // Act - call the method that should set and clear the lock.
+ $result = $this->sut->onboarding_test_account_init( $location );
+
+ // Assert that the result is successful.
+ $this->assertEquals( $expected_response, $result );
+
+ // Assert that the lock was set and then cleared.
+ $this->assertCount( 2, $lock_states, 'Expected exactly 2 lock state changes: set lock (timestamp) and clear lock (0)' );
+ $this->assertSame( $current_time, $lock_states[0], 'First lock state should be current timestamp' );
+ $this->assertSame( 0, $lock_states[1], 'Second lock state should be 0 (cleared)' );
+ }
+
+ /**
+ * Test that onboarding lock is cleared even when an exception occurs.
+ */
+ public function test_onboarding_lock_cleared_on_exception(): void {
+ $location = 'US';
+
+ // Arrange the WPCOM connection.
+ $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 );
+
+ // Track onboarding lock state changes.
+ $lock_states = array();
+ $current_time = $this->current_time;
+
+ // Arrange the NOX profile and lock tracking.
+ $this->mockable_proxy->register_function_mocks(
+ array(
+ 'get_option' => function ( $option_name, $default_value = null ) use ( &$lock_states ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ return array();
+ }
+ if ( WooPaymentsService::NOX_ONBOARDING_LOCKED_KEY === $option_name ) {
+ return end( $lock_states ) !== false ? end( $lock_states ) : $default_value;
+ }
+ return $default_value;
+ },
+ 'update_option' => function ( $option_name, $value ) use ( &$lock_states, $current_time ) {
+ if ( WooPaymentsService::NOX_ONBOARDING_LOCKED_KEY === $option_name ) {
+ $lock_states[] = $value;
+ }
+ return true;
+ },
+ )
+ );
+
+ // Mock REST API to throw an exception.
+ $this->mockable_proxy->register_static_mocks(
+ array(
+ Utils::class => array(
+ 'rest_endpoint_post_request' => function ( string $endpoint ) {
+ if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
+ throw new \Exception( 'Simulated API failure' );
+ }
+ throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
+ },
+ ),
+ )
+ );
+
+ // Act & Assert - expect the method to handle the exception and still clear the lock.
+ try {
+ $this->sut->onboarding_test_account_init( $location );
+
+ $this->fail( 'Expected an exception to be re-thrown as WP_Error' );
+ } catch ( \Throwable $e ) {
+ // The exception handling should have cleared the lock even though an error occurred.
+ $this->assertInstanceOf( \Exception::class, $e );
+ }
+
+ // Assert that the lock was set and then cleared despite the exception.
+ $this->assertCount( 2, $lock_states, 'Expected exactly 2 lock state changes even with exception: set lock (timestamp) and clear lock (0)' );
+ $this->assertSame( $current_time, $lock_states[0], 'First lock state should be current timestamp' );
+ $this->assertSame( 0, $lock_states[1], 'Second lock state should be 0 (cleared) even after exception' );
+ }
+
+ /**
+ * Test onboarding_test_account_init.
+ *
+ * @return void
+ */
+ public function test_onboarding_test_account_init() {
+ $location = 'US';
+
+ // Arrange the WPCOM connection.
+ // Make it working since it is a dependency for the step.
+ $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.
+ $step_id = WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT;
+ $started_timestamp = $this->current_time - 100;
+ $stored_profile = array(
+ 'onboarding' => array(
+ $location => array(
+ 'steps' => array(
+ $step_id => array(
+ 'statuses' => array(
+ WooPaymentsService::ONBOARDING_STEP_STATUS_STARTED => $started_timestamp,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ $updated_stored_profiles = array();
+ $this->mockable_proxy->register_function_mocks(
+ array(
+ 'get_option' => function ( $option_name, $default_value = null ) use ( $stored_profile, &$updated_stored_profiles ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ // Chain the responses to simulate the sequence of DB updates.
+ 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 ) {
+ 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;
+ },
+ )
+ );
+
+ // Arrange the REST API requests.
$requests_made = array();
$expected_response = array(
'success' => true,
@@ -10770,4 +11386,48 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
)
);
}
+
+ /**
+ * Helper method to create a logger test double that captures log calls.
+ *
+ * @param array $captured_calls The captured log calls, by reference. Each captured call is
+ * an array with 'level', 'message', and 'context' keys.
+ *
+ * @return object The logger test double.
+ */
+ private function mock_capturing_logger( array &$captured_calls ): object {
+ return new class( $captured_calls ) {
+ /**
+ * The captured log calls.
+ *
+ * @var array
+ */
+ public $calls;
+
+ /**
+ * Constructor.
+ *
+ * @param array $calls The captured log calls, by reference.
+ */
+ public function __construct( array &$calls ) {
+ $this->calls = &$calls;
+ }
+
+ /**
+ * Capture a log call of any level (e.g. warning, error).
+ *
+ * @param string $name The log level method being called.
+ * @param array $arguments The log message and context.
+ *
+ * @return void
+ */
+ public function __call( string $name, array $arguments ) {
+ $this->calls[] = array(
+ 'level' => $name,
+ 'message' => $arguments[0] ?? '',
+ 'context' => $arguments[1] ?? array(),
+ );
+ }
+ };
+ }
}