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 ),
+ );
+ }
}