Commit 26cf91b016 for woocommerce

commit 26cf91b01618e558c4e418b29ce694c388e0fc48
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Wed Nov 26 11:49:42 2025 +0900

    Fix Tracks events rejected due to bracket notation in array properties (#61729)

    * Fix Tracks events rejected due to bracket notation in array properties

    When arrays are passed to WC_Tracks::record_event(), http_build_query()
    creates bracket notation in property names (e.g., prop[0], prop[1]) which
    violates the required naming pattern and causes event rejection.

    This fix implements automatic array sanitization in validate_and_sanitize():
    - Empty arrays → empty string
    - Indexed arrays with scalars → comma-separated string (URL-encoded)
    - Associative/nested arrays → JSON string

    Aligns with Jetpack consumer-side fix: https://github.com/Automattic/jetpack/pull/45544

    Changes:
    - Add sanitize_property_values() method (PHP 7.4+ compatible)
    - Integrate into validate_and_sanitize() preserving object return type
    - Document array handling in WC_Tracks::record_event() PHPDoc
    - Add 8 comprehensive unit tests for all array scenarios

    🤖 Generated with [Claude Code](https://claude.com/claude-code)

    Co-Authored-By: Claude <noreply@anthropic.com>

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Refactor WC_Tracks_Event to remove rawurlencode for indexed arrays

    Updated the handling of indexed arrays in WC_Tracks_Event to directly use implode for creating comma-separated strings, removing unnecessary URL encoding. Adjusted unit tests to verify the expected string outputs without checking for bracket notation or JSON encoding, ensuring cleaner assertions for URL parameters.

    Changes:
    - Simplified value assignment for indexed arrays
    - Updated assertions in unit tests to check for string containment instead of exact matches

    This change aligns with the recent improvements in array handling for event tracking.

    * Update plugins/woocommerce/includes/tracks/class-wc-tracks-event.php

    Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

    * Fix lint

    ---------

    Co-authored-by: Claude <noreply@anthropic.com>
    Co-authored-by: github-actions <github-actions@github.com>
    Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/61729-fix-tracks-array-serialization b/plugins/woocommerce/changelog/61729-fix-tracks-array-serialization
new file mode 100644
index 0000000000..ddd29f7c6c
--- /dev/null
+++ b/plugins/woocommerce/changelog/61729-fix-tracks-array-serialization
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Tracks events being rejected when array properties are passed by automatically converting arrays to valid formats
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/tracks/class-wc-tracks-event.php b/plugins/woocommerce/includes/tracks/class-wc-tracks-event.php
index 4d2bbe1688..3e3d9a656f 100644
--- a/plugins/woocommerce/includes/tracks/class-wc-tracks-event.php
+++ b/plugins/woocommerce/includes/tracks/class-wc-tracks-event.php
@@ -66,7 +66,7 @@ class WC_Tracks_Event {
 	 * Annotate the event with all relevant info.
 	 *
 	 * @param  array $event Event arguments.
-	 * @return bool|WP_Error True on success, WP_Error on failure.
+	 * @return object|WP_Error Event object on success, WP_Error on failure.
 	 */
 	public static function validate_and_sanitize( $event ) {
 		$event = (object) $event;
@@ -103,6 +103,9 @@ class WC_Tracks_Event {
 			}
 		}

+		// Sanitize array values to prevent bracket notation in serialization.
+		$_event = self::sanitize_property_values( $_event );
+
 		return $_event;
 	}

@@ -131,6 +134,54 @@ class WC_Tracks_Event {
 		return esc_url_raw( WC_Tracks_Client::PIXEL . '?' . http_build_query( $validated ) );
 	}

+	/**
+	 * Sanitize property values to ensure they can be safely serialized.
+	 *
+	 * Converts array values to appropriate formats to prevent http_build_query()
+	 * from creating bracket notation (e.g., prop[0], prop[1]) which violates
+	 * the property name regex.
+	 *
+	 * @param object|array $properties Event properties as object or array.
+	 * @return object|array Sanitized properties in the same type as input.
+	 */
+	private static function sanitize_property_values( $properties ) {
+		$is_object = is_object( $properties );
+		$props     = $is_object ? get_object_vars( $properties ) : $properties;
+
+		foreach ( $props as $key => $value ) {
+			if ( ! is_array( $value ) ) {
+				continue;
+			}
+
+			if ( ! $value ) {
+				// Empty array becomes empty string.
+				$props[ $key ] = '';
+				continue;
+			}
+
+			// Check if array is indexed (not associative) and contains only scalar values.
+			$is_indexed_array = array_keys( $value ) === range( 0, count( $value ) - 1 );
+			$has_scalar_only  = ! array_filter(
+				$value,
+				function ( $item ) {
+					return is_array( $item ) || is_object( $item );
+				}
+			);
+
+			if ( $is_indexed_array && $has_scalar_only ) {
+				// Indexed arrays with scalar values: join as comma string.
+				$props[ $key ] = implode( ',', array_map( 'strval', $value ) );
+				continue;
+			}
+
+			// Associative arrays or nested arrays become JSON strings.
+			$encoded       = wp_json_encode( $value, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES );
+			$props[ $key ] = ( false === $encoded ) ? '' : $encoded;
+		}
+
+		return $is_object ? (object) $props : $props;
+	}
+
 	/**
 	 * Check if event name is valid.
 	 *
diff --git a/plugins/woocommerce/includes/tracks/class-wc-tracks.php b/plugins/woocommerce/includes/tracks/class-wc-tracks.php
index 6e5ded362f..86d447b9e0 100644
--- a/plugins/woocommerce/includes/tracks/class-wc-tracks.php
+++ b/plugins/woocommerce/includes/tracks/class-wc-tracks.php
@@ -92,6 +92,26 @@ class WC_Tracks {
 	 * Record an event in Tracks - this is the preferred way to record events from PHP.
 	 * Note: the event request won't be made if $properties has a member called `error`.
 	 *
+	 * Array values in event properties are automatically converted to prevent invalid property names:
+	 * - Indexed arrays (e.g., ['a', 'b', 'c']) become comma-separated strings: 'a,b,c'
+	 * - Associative arrays (e.g., ['key' => 'val']) become JSON strings: '{"key":"val"}'
+	 * - Empty arrays become empty strings
+	 *
+	 * Examples:
+	 *     // Indexed array - becomes comma-separated string
+	 *     WC_Tracks::record_event( 'checkout_viewed', array(
+	 *         'blocks' => array( 'woocommerce/cart-items', 'woocommerce/checkout-totals' )
+	 *     ) );
+	 *     // Results in: blocks=woocommerce%2Fcart-items%2Cwoocommerce%2Fcheckout-totals
+	 *
+	 *     // Associative array - becomes JSON string
+	 *     WC_Tracks::record_event( 'settings_changed', array(
+	 *         'options' => array( 'enabled' => true, 'count' => 5 )
+	 *     ) );
+	 *     // Results in: options=%7B%22enabled%22%3Atrue%2C%22count%22%3A5%7D
+	 *
+	 * For complex structures, consider explicitly JSON-encoding before passing to record_event().
+	 *
 	 * @param string $event_name The name of the event.
 	 * @param array  $event_properties Custom properties to send with the event.
 	 * @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-tracks-test.php b/plugins/woocommerce/tests/php/includes/class-wc-tracks-test.php
index c854522c8d..1f49fda0f6 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-tracks-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-tracks-test.php
@@ -267,4 +267,219 @@ class WC_Tracks_Test extends \WC_Unit_Test_Case {
 		// Clean up.
 		delete_option( \WC_Install::STORE_ID_OPTION );
 	}
+
+	/**
+	 * Test that indexed arrays are converted to comma-separated strings.
+	 */
+	public function test_array_property_indexed_array() {
+		$event_props = array(
+			'_en'         => 'test_event',
+			'_ts'         => WC_Tracks_Client::build_timestamp(),
+			'blocks'      => array( 'block-a', 'block-b', 'block-c' ),
+			'simple_prop' => 'value',
+		);
+
+		$event = new \WC_Tracks_Event( $event_props );
+		$url   = $event->build_pixel_url();
+
+		// Verify URL was built successfully.
+		$this->assertNotEmpty( $url );
+		$this->assertStringContainsString( 'block-a%2Cblock-b%2Cblock-c', $url );
+
+		// Parse the query string.
+		$parsed_url = wp_parse_url( $url );
+		parse_str( $parsed_url['query'], $query_params );
+		// Verify no bracket notation in URL (parse_str would create array values if brackets were present).
+		foreach ( $query_params as $key => $value ) {
+			$this->assertIsNotArray( $value, "Property '$key' should not be an array after parse_str (indicates bracket notation in URL)" );
+		}
+	}
+
+	/**
+	 * Test that associative arrays are converted to JSON strings.
+	 */
+	public function test_array_property_associative_array() {
+		$event_props = array(
+			'_en'     => 'test_event',
+			'_ts'     => WC_Tracks_Client::build_timestamp(),
+			'options' => array(
+				'enabled' => true,
+				'count'   => 5,
+				'name'    => 'test',
+			),
+		);
+
+		$event = new \WC_Tracks_Event( $event_props );
+		$url   = $event->build_pixel_url();
+
+		// Verify URL was built successfully.
+		$this->assertNotEmpty( $url );
+
+		// Parse the query string.
+		$parsed_url = wp_parse_url( $url );
+		$this->assertStringContainsString( 'options=%7B%22enabled%22%3Atrue%2C%22count%22%3A5%2C%22name%22%3A%22test%22%7D', $url );
+
+		parse_str( $parsed_url['query'], $query_params );
+		// Verify no bracket notation in URL (parse_str would create array values if brackets were present).
+		foreach ( $query_params as $key => $value ) {
+			$this->assertIsNotArray( $value, "Property '$key' should not be an array after parse_str (indicates bracket notation in URL)" );
+		}
+	}
+
+	/**
+	 * Test that empty arrays are converted to empty strings.
+	 */
+	public function test_array_property_empty_array() {
+		$event_props = array(
+			'_en'        => 'test_event',
+			'_ts'        => WC_Tracks_Client::build_timestamp(),
+			'empty_arr'  => array(),
+			'other_prop' => 'value',
+		);
+
+		$event = new \WC_Tracks_Event( $event_props );
+		$url   = $event->build_pixel_url();
+
+		// Verify URL was built successfully.
+		$this->assertNotEmpty( $url );
+
+		// Parse the query string.
+		$parsed_url = wp_parse_url( $url );
+		parse_str( $parsed_url['query'], $query_params );
+
+		// Verify empty array became empty string.
+		$this->assertArrayHasKey( 'empty_arr', $query_params );
+		$this->assertEquals( '', $query_params['empty_arr'] );
+	}
+
+	/**
+	 * Test that nested arrays (indexed with associative) are handled.
+	 */
+	public function test_array_property_nested_array() {
+		$event_props = array(
+			'_en'   => 'test_event',
+			'_ts'   => WC_Tracks_Client::build_timestamp(),
+			'items' => array(
+				array(
+					'id'   => 1,
+					'name' => 'Item 1',
+				),
+				array(
+					'id'   => 2,
+					'name' => 'Item 2',
+				),
+			),
+		);
+
+		$event = new \WC_Tracks_Event( $event_props );
+		$url   = $event->build_pixel_url();
+
+		// Verify URL was built successfully.
+		$this->assertNotEmpty( $url );
+		$this->assertStringContainsString( 'items=%5B%7B%22id%22%3A1%2C%22name%22%3A%22Item+1%22%7D%2C%7B%22id%22%3A2%2C%22name%22%3A%22Item+2%22%7D%5D', $url );
+
+		// Parse the query string.
+		$parsed_url = wp_parse_url( $url );
+		parse_str( $parsed_url['query'], $query_params );
+		// Verify no bracket notation in URL (parse_str would create array values if brackets were present).
+		foreach ( $query_params as $key => $value ) {
+			$this->assertIsNotArray( $value, "Property '$key' should not be an array after parse_str (indicates bracket notation in URL)" );
+		}
+	}
+
+	/**
+	 * Test array with values containing commas.
+	 */
+	public function test_array_property_with_commas() {
+		$event_props = array(
+			'_en'  => 'test_event',
+			'_ts'  => WC_Tracks_Client::build_timestamp(),
+			'tags' => array( 'tag1', 'tag,with,commas', 'tag3' ),
+		);
+
+		$event = new \WC_Tracks_Event( $event_props );
+		$url   = $event->build_pixel_url();
+
+		// Verify URL was built successfully.
+		$this->assertNotEmpty( $url );
+		$this->assertStringContainsString( 'tags=tag1%2Ctag%2Cwith%2Ccommas%2Ctag3', $url );
+
+		// Parse the query string.
+		$parsed_url = wp_parse_url( $url );
+		parse_str( $parsed_url['query'], $query_params );
+		// Verify no bracket notation in URL (parse_str would create array values if brackets were present).
+		foreach ( $query_params as $key => $value ) {
+			$this->assertIsNotArray( $value, "Property '$key' should not be an array after parse_str (indicates bracket notation in URL)" );
+		}
+	}
+
+	/**
+	 * Test that all property names remain valid after array conversion.
+	 */
+	public function test_array_property_names_remain_valid() {
+		$event_props = array(
+			'_en'                  => 'test_event',
+			'_ts'                  => WC_Tracks_Client::build_timestamp(),
+			'simple_indexed_array' => array( 'a', 'b', 'c' ),
+			'complex_associative'  => array( 'key' => 'value' ),
+			'empty_array'          => array(),
+		);
+
+		$event = new \WC_Tracks_Event( $event_props );
+		$url   = $event->build_pixel_url();
+
+		// Verify URL was built successfully.
+		$this->assertNotEmpty( $url );
+
+		// Parse the query string directly (without parse_str to avoid bracket notation conversion).
+		$parsed_url   = wp_parse_url( $url );
+		$query_string = $parsed_url['query'];
+
+		// Extract all parameter names from the query string.
+		preg_match_all( '/([^=&]+)=/', $query_string, $matches );
+		$param_names = $matches[1];
+
+		// Verify all property names are valid (match regex).
+		foreach ( $param_names as $param_name ) {
+			// URL decode the parameter name to get the actual property name.
+			$decoded_name = urldecode( $param_name );
+			$this->assertMatchesRegularExpression(
+				'/^[a-z_][a-z0-9_]*$/',
+				$decoded_name,
+				"Property name '$decoded_name' should match the valid naming pattern"
+			);
+		}
+	}
+
+	/**
+	 * Test multiple array properties in the same event.
+	 */
+	public function test_multiple_array_properties() {
+		$event_props = array(
+			'_en'      => 'test_event',
+			'_ts'      => WC_Tracks_Client::build_timestamp(),
+			'blocks'   => array( 'block-a', 'block-b' ),
+			'settings' => array( 'enabled' => true ),
+			'empty'    => array(),
+			'normal'   => 'regular_value',
+		);
+
+		$event = new \WC_Tracks_Event( $event_props );
+		$url   = $event->build_pixel_url();
+
+		// Verify URL was built successfully.
+		$this->assertNotEmpty( $url );
+		$this->assertStringContainsString( 'blocks=block-a%2Cblock-b', $url );
+		$this->assertStringContainsString( 'settings=%7B%22enabled%22%3Atrue%7D', $url );
+		$this->assertStringContainsString( 'empty=', $url );
+		$this->assertStringContainsString( 'normal=regular_value', $url );
+
+		// Parse the query string.
+		$parsed_url = wp_parse_url( $url );
+		parse_str( $parsed_url['query'], $query_params );
+		// Verify no bracket notation in URL (parse_str would create array values if brackets were present).
+		foreach ( $query_params as $key => $value ) {
+			$this->assertIsNotArray( $value, "Property '$key' should not be an array after parse_str (indicates bracket notation in URL)" );
+		}
+	}
 }