Commit 4b8a3a2575 for woocommerce

commit 4b8a3a2575243a525414aebf6855a7d6892cb814
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Thu Dec 11 17:57:04 2025 +0200

    Fix excessive debug logging for empty country code in get_provider_links (#62390)

    * fix(payments): skip debug logging for empty country code in get_provider_links

    The get_provider_links() method logged a debug message every time an empty
    country code was passed. Since the country_code parameter is optional and
    defaults to empty string, this caused excessive logging when payment gateways
    were loaded without a country context.

    Only log invalid country codes when a non-empty value is provided, as empty
    string is a valid input for the optional parameter. This eliminates debug spam
    while still tracking genuinely invalid country codes.

    Closes #62380

    * docs: add changelog requirement to development workflow

    Claude memory was missing the requirement that changelog entries must be
    created before PRs. This led to PRs being created without proper changelog
    documentation.

    Add explicit workflow steps and command examples for creating changelog
    entries using `pnpm --filter=<project> changelog add`.

    * docs: add WooCommerce logger mocking guide to unit tests skill

    Document how to use the `woocommerce_logging_class` filter to inject a fake
    logger when testing code that uses `wc_get_logger()` or
    `SafeGlobalFunctionProxy::wc_get_logger()`. This approach is required because
    `register_legacy_proxy_function_mocks` doesn't intercept SafeGlobalFunctionProxy
    calls.

    * Add changelog

diff --git a/.ai/skills/woocommerce-backend-dev/unit-tests.md b/.ai/skills/woocommerce-backend-dev/unit-tests.md
index a55173d15c..475fac9ea2 100644
--- a/.ai/skills/woocommerce-backend-dev/unit-tests.md
+++ b/.ai/skills/woocommerce-backend-dev/unit-tests.md
@@ -8,14 +8,15 @@
 - [Comments in Tests](#comments-in-tests)
 - [Test Configuration](#test-configuration)
 - [Example: Payment Extension Suggestions Tests](#example-payment-extension-suggestions-tests)
+- [Mocking the WooCommerce Logger](#mocking-the-woocommerce-logger)
 - [General Testing Best Practices](#general-testing-best-practices)

 ## Test File Naming and Location

-| Source | Test | Pattern |
-|--------|------|---------|
-| `includes/` classes | `tests/php/includes/{path}/class-wc-{name}-test.php` | Add `-test` suffix |
-| `src/` classes | `tests/php/src/{path}/{name}Test.php` | Append `Test` (no hyphen) |
+| Source               | Test                                                 | Pattern                  |
+| -------------------- | ---------------------------------------------------- | ------------------------ |
+| `includes/` classes  | `tests/php/includes/{path}/class-wc-{name}-test.php` | Add `-test` suffix       |
+| `src/` classes       | `tests/php/src/{path}/{name}Test.php`                | Append `Test` (no hyphen)|

 Test class: Same name as source class + `_Test` or `Test` suffix, extends `WC_Unit_Test_Case`

@@ -227,6 +228,54 @@ When working with payment extension suggestions:
 3. **Tests are separated by merchant type** (online vs offline) as they have different extension counts
 4. **Data providers use descriptive keys** (country names) for better test output

+## Mocking the WooCommerce Logger
+
+When testing code that uses `wc_get_logger()` (directly or via `SafeGlobalFunctionProxy::wc_get_logger()`), use the `woocommerce_logging_class` filter to inject a fake logger.
+
+### Why the Filter Approach?
+
+- `register_legacy_proxy_function_mocks` doesn't intercept `SafeGlobalFunctionProxy` calls
+- Passing an object (not a class name string) bypasses `wc_get_logger()`'s internal cache
+
+### Creating a Fake Logger
+
+The fake logger must implement `WC_Logger_Interface`. Create an anonymous class with public arrays to track calls (`$debug_calls`, `$warning_calls`, etc.) and implement all interface methods (`add`, `log`, `debug`, `info`, `warning`, `error`, `emergency`, `alert`, `critical`, `notice`).
+
+### Using the Fake Logger
+
+```php
+public function test_logs_warning_for_invalid_input(): void {
+    $fake_logger = $this->create_fake_logger();
+
+    // Inject via filter - passing object bypasses cache.
+    add_filter(
+        'woocommerce_logging_class',
+        function () use ( $fake_logger ) {
+            return $fake_logger;
+        }
+    );
+
+    $this->sut->process_input( 'invalid-value' );
+
+    $this->assertCount( 1, $fake_logger->warning_calls );
+
+    remove_all_filters( 'woocommerce_logging_class' ); // Always clean up.
+}
+```
+
+### Key Points
+
+| Aspect       | Detail                                          |
+| ------------ | ----------------------------------------------- |
+| Filter name  | `woocommerce_logging_class`                     |
+| Return value | Object instance (not class name string)         |
+| Interface    | Must implement `WC_Logger_Interface`            |
+| Cleanup      | Always call `remove_all_filters()` after test   |
+
+### Reference
+
+See `PaymentGatewayTest.php:create_fake_logger()` for a complete implementation.
+
 ## General Testing Best Practices

 1. **Always run tests after making changes** to verify functionality
diff --git a/CLAUDE.md b/CLAUDE.md
index 1023622dc5..da5572f894 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -84,6 +84,22 @@ plugins/woocommerce/
 2. Run relevant tests (see `woocommerce-dev-cycle` skill)
 3. Run linting (see `woocommerce-dev-cycle` skill)
 4. Commit only after tests pass and linting is clean
+5. Create changelog entries for each affected package
+6. Create PR only after changelog entries exist
+
+**NEVER create a PR without changelog entries.** Each package modified in the monorepo requires its own changelog entry. Run for each affected package:
+
+```sh
+pnpm --filter=<project> changelog add
+```
+
+Example for WooCommerce Core:
+
+```sh
+pnpm --filter=@woocommerce/plugin-woocommerce changelog add
+```
+
+This command prompts for the change type and description. Run it once per affected package before creating any PR.

 ## Testing Environment

diff --git a/plugins/woocommerce/changelog/fix-WOOPLUG-5988-provider-links-excess-logging b/plugins/woocommerce/changelog/fix-WOOPLUG-5988-provider-links-excess-logging
new file mode 100644
index 0000000000..94a6e0b4e6
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-WOOPLUG-5988-provider-links-excess-logging
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent excessive debug logging from the Payments Settings page.
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/PaymentGateway.php b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/PaymentGateway.php
index b5f20aa38e..c929e754e0 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/PaymentGateway.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/PaymentGateway.php
@@ -276,8 +276,9 @@ class PaymentGateway {
 	public function get_provider_links( WC_Payment_Gateway $payment_gateway, string $country_code = '' ): array {
 		$country_code = strtoupper( sanitize_text_field( $country_code ) );
 		// Validate the country code format - expect ISO 3166-1 alpha-2.
-		if ( strlen( $country_code ) !== 2 || ! ctype_upper( $country_code ) ) {
-			// Log so we can investigate.
+		// Empty country code is valid (parameter is optional), so only validate non-empty values.
+		if ( '' !== $country_code && ( strlen( $country_code ) !== 2 || ! ctype_upper( $country_code ) ) ) {
+			// Log so we can investigate non-empty invalid country codes.
 			SafeGlobalFunctionProxy::wc_get_logger()->debug(
 				'Received invalid country code when getting provider links. Ignoring it.',
 				array(
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 e48392c686..fb5061e1b5 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
@@ -2179,4 +2179,366 @@ class PaymentGatewayTest extends WC_Unit_Test_Case {
 		$this->assertArrayHasKey( 'not_supported', $gateway_details['onboarding']['messages'] );
 		$this->assertEquals( 'This gateway is not supported in your country.', $gateway_details['onboarding']['messages']['not_supported'] );
 	}
+
+	/**
+	 * Test get_provider_links with empty country code does not cause issues.
+	 *
+	 * Empty country code is a valid input (parameter is optional) and should not trigger
+	 * any debug logging or errors.
+	 *
+	 * @see https://github.com/woocommerce/woocommerce/issues/62380
+	 */
+	public function test_get_provider_links_with_empty_country_code() {
+		// Arrange - Create a gateway with provider links.
+		$fake_gateway = new FakePaymentGateway(
+			'gateway1',
+			array(
+				'provider_links' => array(
+					array(
+						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
+						'url'   => 'https://example.com/docs',
+					),
+				),
+			)
+		);
+
+		// Act - Call with empty country code (the default when not provided).
+		$links = $this->sut->get_provider_links( $fake_gateway, '' );
+
+		// Assert - Should return links successfully without any issues.
+		$this->assertCount( 1, $links );
+		$this->assertEquals( PaymentsProviders::LINK_TYPE_DOCS, $links[0]['_type'] );
+		$this->assertEquals( 'https://example.com/docs', $links[0]['url'] );
+	}
+
+	/**
+	 * Test get_provider_links with various country code formats.
+	 *
+	 * This tests that valid ISO 3166-1 alpha-2 country codes work correctly,
+	 * while invalid (non-empty) country codes are handled gracefully.
+	 *
+	 * @dataProvider data_provider_country_codes
+	 *
+	 * @param string $country_code The country code to test.
+	 * @param bool   $is_valid     Whether the country code is valid.
+	 */
+	public function test_get_provider_links_with_various_country_codes( string $country_code, bool $is_valid ) {
+		// Arrange - Create a mock gateway with the get_provider_links method.
+		$gateway = $this->getMockBuilder( 'WC_Payment_Gateway' )
+			->disableOriginalConstructor()
+			->addMethods( array( 'get_provider_links' ) )
+			->getMock();
+
+		$gateway->id = 'test_gateway';
+
+		// Expected links to be returned by the mock.
+		$expected_links = array(
+			array(
+				'_type' => PaymentsProviders::LINK_TYPE_DOCS,
+				'url'   => 'https://example.com/docs',
+			),
+		);
+
+		// The get_provider_links method should be called with the sanitized country code.
+		// For valid codes, it should receive the uppercase version.
+		// For invalid codes, it should receive an empty string.
+		$expected_code = $is_valid ? strtoupper( $country_code ) : '';
+		$gateway->expects( $this->once() )
+			->method( 'get_provider_links' )
+			->with( $expected_code )
+			->willReturn( $expected_links );
+
+		// Act.
+		$links = $this->sut->get_provider_links( $gateway, $country_code );
+
+		// Assert - Links should be returned regardless of country code validity.
+		$this->assertCount( 1, $links );
+		$this->assertEquals( PaymentsProviders::LINK_TYPE_DOCS, $links[0]['_type'] );
+	}
+
+	/**
+	 * Data provider for country code tests.
+	 *
+	 * @return array Test cases with country code and validity flag.
+	 */
+	public function data_provider_country_codes(): array {
+		return array(
+			'empty string (valid - optional parameter)'    => array( '', true ),
+			'valid US code'                                => array( 'US', true ),
+			'valid lowercase us code (auto-uppercased)'    => array( 'us', true ),
+			'valid GB code'                                => array( 'GB', true ),
+			'single character (invalid)'                   => array( 'U', false ),
+			'three characters (invalid)'                   => array( 'USA', false ),
+			'numeric country code (invalid for alpha-2)'   => array( '12', false ),
+			'mixed alphanumeric (invalid)'                 => array( 'U1', false ),
+			'whitespace only (invalid after sanitization)' => array( '  ', false ),
+			'special characters (invalid after sanitization)' => array( '@@', false ),
+		);
+	}
+
+	/**
+	 * Create a fake logger that tracks all log calls.
+	 *
+	 * Implements WC_Logger_Interface so it can be injected via the
+	 * woocommerce_logging_class filter.
+	 *
+	 * @return object A fake logger with tracking capabilities.
+	 */
+	private function create_fake_logger(): object {
+		// phpcs:disable Squiz.Commenting, Squiz.Classes.ClassFileName.NoMatch
+		return new class() implements \WC_Logger_Interface {
+			public array $debug_calls   = array();
+			public array $info_calls    = array();
+			public array $warning_calls = array();
+			public array $error_calls   = array();
+
+			public function add( $handle, $message, $level = \WC_Log_Levels::NOTICE ) {
+				unset( $handle, $message, $level ); // Avoid parameter not used PHPCS errors.
+				return true;
+			}
+
+			public function log( $level, $message, $context = array() ) {
+				unset( $level, $message, $context ); // Avoid parameter not used PHPCS errors.
+			}
+
+			public function emergency( $message, $context = array() ) {
+				unset( $message, $context ); // Avoid parameter not used PHPCS errors.
+			}
+
+			public function alert( $message, $context = array() ) {
+				unset( $message, $context ); // Avoid parameter not used PHPCS errors.
+			}
+
+			public function critical( $message, $context = array() ) {
+				unset( $message, $context ); // Avoid parameter not used PHPCS errors.
+			}
+
+			public function notice( $message, $context = array() ) {
+				unset( $message, $context ); // Avoid parameter not used PHPCS errors.
+			}
+
+			public function debug( $message, $context = array() ) {
+				$this->debug_calls[] = array(
+					'message' => $message,
+					'context' => $context,
+				);
+			}
+
+			public function info( $message, $context = array() ) {
+				$this->info_calls[] = array(
+					'message' => $message,
+					'context' => $context,
+				);
+			}
+
+			public function warning( $message, $context = array() ) {
+				$this->warning_calls[] = array(
+					'message' => $message,
+					'context' => $context,
+				);
+			}
+
+			public function error( $message, $context = array() ) {
+				$this->error_calls[] = array(
+					'message' => $message,
+					'context' => $context,
+				);
+			}
+
+			public function reset() {
+				$this->debug_calls   = array();
+				$this->info_calls    = array();
+				$this->warning_calls = array();
+				$this->error_calls   = array();
+			}
+
+			public function has_any_logs(): bool {
+				return ! empty( $this->debug_calls )
+					|| ! empty( $this->info_calls )
+					|| ! empty( $this->warning_calls )
+					|| ! empty( $this->error_calls );
+			}
+		};
+		// phpcs:enable Squiz.Commenting, Squiz.Classes.ClassFileName.NoMatch
+	}
+
+	/**
+	 * Test that NO logging is triggered for empty country code.
+	 *
+	 * Empty country code is a valid input (parameter is optional), so it should
+	 * not cause any logging at any level.
+	 *
+	 * @see https://github.com/woocommerce/woocommerce/issues/62380
+	 */
+	public function test_get_provider_links_no_logging_for_empty_country_code() {
+		// Arrange.
+		$fake_logger = $this->create_fake_logger();
+
+		// Use the woocommerce_logging_class filter to inject the fake logger.
+		// Passing an object bypasses the cache check and uses the object directly.
+		add_filter(
+			'woocommerce_logging_class',
+			function () use ( $fake_logger ) {
+				return $fake_logger;
+			}
+		);
+
+		$fake_gateway = new FakePaymentGateway(
+			'gateway1',
+			array(
+				'provider_links' => array(
+					array(
+						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
+						'url'   => 'https://example.com/docs',
+					),
+				),
+			)
+		);
+
+		// Act - Call with empty country code.
+		$this->sut->get_provider_links( $fake_gateway, '' );
+
+		// Assert - No logging should have occurred at any level.
+		$this->assertEmpty( $fake_logger->debug_calls, 'Debug logging should NOT be triggered for empty country code' );
+		$this->assertEmpty( $fake_logger->info_calls, 'Info logging should NOT be triggered for empty country code' );
+		$this->assertEmpty( $fake_logger->warning_calls, 'Warning logging should NOT be triggered for empty country code' );
+		$this->assertEmpty( $fake_logger->error_calls, 'Error logging should NOT be triggered for empty country code' );
+		$this->assertFalse( $fake_logger->has_any_logs(), 'No logging should occur for empty country code' );
+
+		// Clean up - Remove filter.
+		remove_all_filters( 'woocommerce_logging_class' );
+	}
+
+	/**
+	 * Test that NO logging is triggered for valid country codes.
+	 *
+	 * Valid ISO 3166-1 alpha-2 country codes should not cause any logging.
+	 *
+	 * @dataProvider data_provider_valid_country_codes_for_logging
+	 *
+	 * @param string $country_code The country code to test.
+	 */
+	public function test_get_provider_links_no_logging_for_valid_country_codes( string $country_code ) {
+		// Arrange.
+		$fake_logger = $this->create_fake_logger();
+
+		// Use the woocommerce_logging_class filter to inject the fake logger.
+		// Passing an object bypasses the cache check and uses the object directly.
+		add_filter(
+			'woocommerce_logging_class',
+			function () use ( $fake_logger ) {
+				return $fake_logger;
+			}
+		);
+
+		$fake_gateway = new FakePaymentGateway(
+			'gateway1',
+			array(
+				'provider_links' => array(
+					array(
+						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
+						'url'   => 'https://example.com/docs',
+					),
+				),
+			)
+		);
+
+		// Act.
+		$this->sut->get_provider_links( $fake_gateway, $country_code );
+
+		// Assert - No logging should have occurred at any level.
+		$this->assertEmpty( $fake_logger->debug_calls, "Debug logging should NOT be triggered for valid country code: {$country_code}" );
+		$this->assertEmpty( $fake_logger->info_calls, "Info logging should NOT be triggered for valid country code: {$country_code}" );
+		$this->assertEmpty( $fake_logger->warning_calls, "Warning logging should NOT be triggered for valid country code: {$country_code}" );
+		$this->assertEmpty( $fake_logger->error_calls, "Error logging should NOT be triggered for valid country code: {$country_code}" );
+		$this->assertFalse( $fake_logger->has_any_logs(), "No logging should occur for valid country code: {$country_code}" );
+
+		// Clean up - Remove filter.
+		remove_all_filters( 'woocommerce_logging_class' );
+	}
+
+	/**
+	 * Data provider for valid country codes (no logging expected).
+	 *
+	 * @return array Test cases with valid country codes.
+	 */
+	public function data_provider_valid_country_codes_for_logging(): array {
+		return array(
+			'US'        => array( 'US' ),
+			'GB'        => array( 'GB' ),
+			'DE'        => array( 'DE' ),
+			'lowercase' => array( 'us' ), // Will be uppercased to valid "US".
+		);
+	}
+
+	/**
+	 * Test that debug logging IS triggered for invalid non-empty country codes.
+	 *
+	 * Invalid country codes (non-empty, not ISO 3166-1 alpha-2) should trigger
+	 * debug logging so developers can investigate.
+	 *
+	 * @dataProvider data_provider_invalid_country_codes_for_logging
+	 *
+	 * @param string $country_code The invalid country code to test.
+	 */
+	public function test_get_provider_links_logging_for_invalid_country_codes( string $country_code ) {
+		// Arrange.
+		$fake_logger = $this->create_fake_logger();
+
+		// Use the woocommerce_logging_class filter to inject the fake logger.
+		// Passing an object bypasses the cache check and uses the object directly.
+		add_filter(
+			'woocommerce_logging_class',
+			function () use ( $fake_logger ) {
+				return $fake_logger;
+			}
+		);
+
+		$fake_gateway = new FakePaymentGateway(
+			'gateway1',
+			array(
+				'provider_links' => array(
+					array(
+						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
+						'url'   => 'https://example.com/docs',
+					),
+				),
+			)
+		);
+
+		// Act.
+		$this->sut->get_provider_links( $fake_gateway, $country_code );
+
+		// Assert - Debug logging SHOULD have been triggered for invalid country code.
+		$this->assertCount( 1, $fake_logger->debug_calls, "Debug logging SHOULD be triggered for invalid country code: {$country_code}" );
+		$this->assertEmpty( $fake_logger->info_calls, "Info logging should NOT be triggered for invalid country code: {$country_code}" );
+		$this->assertEmpty( $fake_logger->warning_calls, "Warning logging should NOT be triggered for invalid country code: {$country_code}" );
+		$this->assertEmpty( $fake_logger->error_calls, "Error logging should NOT be triggered for invalid country code: {$country_code}" );
+
+		// Verify the debug call has the expected context.
+		$debug_call = $fake_logger->debug_calls[0];
+		$this->assertStringContainsString( 'invalid country code', $debug_call['message'] );
+		$this->assertArrayHasKey( 'source', $debug_call['context'] );
+		$this->assertEquals( 'settings-payments', $debug_call['context']['source'] );
+		$this->assertArrayHasKey( 'gateway', $debug_call['context'] );
+		$this->assertEquals( 'gateway1', $debug_call['context']['gateway'] );
+		$this->assertArrayHasKey( 'country', $debug_call['context'] );
+
+		// Clean up - Remove filter.
+		remove_all_filters( 'woocommerce_logging_class' );
+	}
+
+	/**
+	 * Data provider for invalid country codes (logging expected).
+	 *
+	 * @return array Test cases with invalid country codes.
+	 */
+	public function data_provider_invalid_country_codes_for_logging(): array {
+		return array(
+			'three characters'   => array( 'USA' ),
+			'single character'   => array( 'U' ),
+			'numeric'            => array( '12' ),
+			'mixed alphanumeric' => array( 'U1' ),
+		);
+	}
 }