Commit 68ef955ceb3 for woocommerce

commit 68ef955ceb3f2ab9ccb822e349a0ea312c76ee44
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Wed Aug 5 12:51:42 2026 +0300

    [tests] [Payments NOX] Add resilience tests for the payments provider base class (#67400)

    * Add resilience tests for the payments provider base class

    `PaymentGateway` infers gateway state by probing for method names,
    properties and option keys that third-party gateways are free to
    implement however they like. Almost every probe is wrapped in a
    `try/catch ( Throwable )` for that reason, and unrecognised values fall
    back to a documented default. None of that was covered: of the 66 tests
    in the class, none passed a gateway that throws or returns an unexpected
    type, so the resilience was asserted only by the shape of the code.

    Add cases for a gateway that throws from every state probe, one that
    returns non-scalars from `get_option()`, and one that confirms a
    swallowed throwable is still logged at debug level with its gateway and
    source context rather than disappearing.

    Errors are covered alongside exceptions because the catch blocks target
    `Throwable`, and a gateway with a mismatched signature raises `Error`
    rather than `Exception`.

    The tests were checked against mutants: narrowing every catch from
    `Throwable` to `InvalidArgumentException` produces 14 errors, and
    narrowing only the one in `is_account_connected()` produces 6.

    * Exercise the option probes in the non-scalar test

    The non-scalar case built its gateway on FakePaymentGateway, which defines
    `is_test_mode()`. That is the first probe `is_in_test_mode()` tries, so it
    returned there and the overridden `get_option()` was never called. The
    test asserted only that the result was a boolean, which held whether or
    not the option path ran, so it passed without covering anything.

    Instrumenting the fake to record calls confirmed it: `get_option()` was
    reached in none of the four cases.

    Build the gateway the way the neighbouring option tests do — a
    `WC_Payment_Gateway` mock with only `get_option()` defined — so the
    earlier method and property probes find nothing and the option path is
    the one under test. Assert `false` rather than a boolean type, since an
    unusable value must not read as an enabled test mode.

    Verified the path is now reached by asserting `get_option()` is never
    called, which fails all four cases.

diff --git a/plugins/woocommerce/changelog/add-payment-gateway-provider-resilience-tests b/plugins/woocommerce/changelog/add-payment-gateway-provider-resilience-tests
new file mode 100644
index 00000000000..59e7c688033
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-payment-gateway-provider-resilience-tests
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add tests covering the payments provider base class's resilience to gateways that throw from state probes or return non-scalar option values.
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/PaymentGatewayTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/PaymentGatewayTest.php
index 51e67f22394..3604c688fc8 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/PaymentGatewayTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/PaymentGatewayTest.php
@@ -2679,4 +2679,175 @@ class PaymentGatewayTest extends WC_Unit_Test_Case {
 			'mixed alphanumeric' => array( 'U1' ),
 		);
 	}
+
+	/**
+	 * @testdox Every state probe degrades to a boolean when the gateway throws from it.
+	 *
+	 * The class infers gateway state by calling methods that third-party gateways are
+	 * free to implement however they like, so any of them can throw. Each probe is
+	 * wrapped in a try/catch for that reason; this asserts the wrapping actually holds.
+	 *
+	 * @dataProvider data_provider_throwables_from_gateway
+	 *
+	 * @param \Throwable $to_throw The throwable the gateway raises from every probe.
+	 */
+	public function test_state_probes_survive_a_throwing_gateway( \Throwable $to_throw ) {
+		// Arrange.
+		$fake_gateway = new class( 'throwing_gateway', array() ) extends FakePaymentGateway {
+			/**
+			 * The throwable to raise.
+			 *
+			 * @var \Throwable
+			 */
+			public $to_throw;
+
+			// phpcs:disable Squiz.Commenting.FunctionComment.Missing, Squiz.Commenting.FunctionComment.MissingParamTag
+			public function get_option( $key, $empty_value = null ) {
+				throw $this->to_throw;
+			}
+
+			public function needs_setup() {
+				throw $this->to_throw;
+			}
+
+			public function is_test_mode() {
+				throw $this->to_throw;
+			}
+
+			public function is_dev_mode() {
+				throw $this->to_throw;
+			}
+
+			public function is_account_connected() {
+				throw $this->to_throw;
+			}
+
+			public function is_onboarding_started() {
+				throw $this->to_throw;
+			}
+
+			public function is_onboarding_completed() {
+				throw $this->to_throw;
+			}
+
+			public function is_in_test_mode_onboarding() {
+				throw $this->to_throw;
+			}
+			// phpcs:enable Squiz.Commenting.FunctionComment.Missing, Squiz.Commenting.FunctionComment.MissingParamTag
+		};
+
+		$fake_gateway->to_throw = $to_throw;
+
+		// Act & Assert - nothing escapes, and every probe still answers with a boolean.
+		$this->assertIsBool( $this->sut->needs_setup( $fake_gateway ), 'needs_setup() should not let the throwable escape' );
+		$this->assertIsBool( $this->sut->is_in_test_mode( $fake_gateway ), 'is_in_test_mode() should not let the throwable escape' );
+		$this->assertIsBool( $this->sut->is_in_dev_mode( $fake_gateway ), 'is_in_dev_mode() should not let the throwable escape' );
+		$this->assertIsBool( $this->sut->is_account_connected( $fake_gateway ), 'is_account_connected() should not let the throwable escape' );
+		$this->assertIsBool( $this->sut->is_onboarding_started( $fake_gateway ), 'is_onboarding_started() should not let the throwable escape' );
+		$this->assertIsBool( $this->sut->is_onboarding_completed( $fake_gateway ), 'is_onboarding_completed() should not let the throwable escape' );
+		$this->assertIsBool( $this->sut->is_in_test_mode_onboarding( $fake_gateway ), 'is_in_test_mode_onboarding() should not let the throwable escape' );
+	}
+
+	/**
+	 * Data provider for throwables a gateway might raise from a state probe.
+	 *
+	 * Errors are included alongside exceptions because the catch blocks target
+	 * Throwable, and a gateway with a mismatched signature raises an Error rather
+	 * than an Exception.
+	 *
+	 * @return array Test cases with throwables.
+	 */
+	public function data_provider_throwables_from_gateway(): array {
+		return array(
+			'exception'            => array( new \RuntimeException( 'bogus runtime failure' ) ),
+			'error'                => array( new \Error( 'bogus error' ) ),
+			'type error'           => array( new \TypeError( 'bogus type error' ) ),
+			'argument count error' => array( new \ArgumentCountError( 'bogus argument count error' ) ),
+		);
+	}
+
+	/**
+	 * @testdox A throwing state probe is logged for debugging rather than swallowed silently.
+	 */
+	public function test_throwing_state_probe_is_logged() {
+		// Arrange.
+		$fake_logger = $this->create_fake_logger();
+
+		add_filter(
+			'woocommerce_logging_class',
+			function () use ( $fake_logger ) {
+				return $fake_logger;
+			}
+		);
+
+		$fake_gateway = new class( 'throwing_gateway', array() ) extends FakePaymentGateway {
+			// phpcs:disable Squiz.Commenting.FunctionComment.Missing, Squiz.Commenting.FunctionComment.MissingParamTag
+			public function is_account_connected() {
+				throw new \RuntimeException( 'bogus account connected failure' );
+			}
+			// phpcs:enable Squiz.Commenting.FunctionComment.Missing, Squiz.Commenting.FunctionComment.MissingParamTag
+		};
+
+		// Act.
+		$this->sut->is_account_connected( $fake_gateway );
+
+		// Assert.
+		$this->assertNotEmpty( $fake_logger->debug_calls, 'A swallowed throwable should still be logged at debug level' );
+
+		$debug_call = $fake_logger->debug_calls[0];
+		$this->assertArrayHasKey( 'source', $debug_call['context'] );
+		$this->assertEquals( 'settings-payments', $debug_call['context']['source'] );
+		$this->assertArrayHasKey( 'gateway', $debug_call['context'] );
+		$this->assertEquals( 'throwing_gateway', $debug_call['context']['gateway'] );
+		$this->assertArrayHasKey( 'exception', $debug_call['context'] );
+
+		// Clean up.
+		remove_all_filters( 'woocommerce_logging_class' );
+	}
+
+	/**
+	 * @testdox Test mode detection returns false when get_option() returns a non-scalar.
+	 *
+	 * The gateway settings array is stored data, so a malformed entry can hand back a type
+	 * the option probes do not expect. The gateway is mocked with only get_option() defined
+	 * so the earlier method and property probes are skipped and the option path is the one
+	 * under test.
+	 *
+	 * @dataProvider data_provider_non_scalar_option_values
+	 *
+	 * @param mixed $value The value the gateway returns from get_option().
+	 */
+	public function test_is_in_test_mode_returns_false_for_non_scalar_option_values( $value ) {
+		// Arrange.
+		$gateway = $this->getMockBuilder( 'WC_Payment_Gateway' )
+			->disableOriginalConstructor()
+			->onlyMethods( array( 'get_option' ) )
+			->getMock();
+
+		$gateway->id = 'junk_option_gateway';
+
+		$gateway->expects( $this->atLeastOnce() )
+			->method( 'get_option' )
+			->willReturn( $value );
+
+		// Act.
+		$result = $this->sut->is_in_test_mode( $gateway );
+
+		// Assert - an unusable value must not be read as an enabled test mode.
+		$this->assertFalse( $result );
+	}
+
+	/**
+	 * Data provider for non-scalar values a gateway might return from get_option().
+	 *
+	 * @return array Test cases with non-scalar option values.
+	 */
+	public function data_provider_non_scalar_option_values(): array {
+		return array(
+			'array'        => array( array( 'unexpected' => 'array' ) ),
+			'nested array' => array( array( array( 'deep' ) ) ),
+			'object'       => array( new stdClass() ),
+			'null'         => array( null ),
+		);
+	}
 }