Commit cdf2cc690e4 for woocommerce

commit cdf2cc690e4dec565d32498883f27a4152d7e089
Author: Mike Jolley <mike.jolley@me.com>
Date:   Fri Mar 6 15:58:39 2026 +0000

    Fix customers report includes/excludes params incorrectly filtering by `customer_id` (#63232)

    * Update datastore to look at correct columns

    * Expand test coverage

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

    * Make client search by strings rather than client ids

    * Allow for either strings or IDs when using includes/excludes filter

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

    * Fix customer ID merging

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/63232-fix-customer-reports-datastore-include-exclude-params b/plugins/woocommerce/changelog/63232-fix-customer-reports-datastore-include-exclude-params
new file mode 100644
index 00000000000..35cb162e90a
--- /dev/null
+++ b/plugins/woocommerce/changelog/63232-fix-customer-reports-datastore-include-exclude-params
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Customers Reports advanced filters (email, username, name) by consolidating autocompleter customer IDs into customers/customers_exclude params in the Controller, and add customers_exclude support.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
index 0d612929051..b4de6656e0e 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
@@ -84,6 +84,7 @@ class Controller extends GenericController implements ExportableInterface {
 		$args['last_order_before']   = $request['last_order_before'];
 		$args['last_order_after']    = $request['last_order_after'];
 		$args['customers']           = $request['customers'];
+		$args['customers_exclude']   = $request['customers_exclude'];
 		$args['users']               = $request['users'];
 		$args['force_cache_refresh'] = $request['force_cache_refresh'];
 		$args['filter_empty']        = $request['filter_empty'];
@@ -97,9 +98,104 @@ class Controller extends GenericController implements ExportableInterface {
 		$normalized_params_date    = TimeInterval::normalize_between_params( $request, $between_params_date, true );
 		$args                      = array_merge( $args, $normalized_params_numeric, $normalized_params_date );

+		$args = self::consolidate_customer_id_filters( $args );
+
+		return $args;
+	}
+
+	/**
+	 * Consolidate customer identity filter IDs into customers/customers_exclude.
+	 *
+	 * When the frontend sends customer IDs via name_includes, email_includes, or
+	 * username_includes, this method collects those IDs and merges them into the
+	 * customers/customers_exclude params so the DataStore filters by customer_id.
+	 *
+	 * Only numeric values are consolidated. String values (e.g. actual email
+	 * addresses or names) are left untouched for the DataStore's exact-match
+	 * filtering.
+	 *
+	 * @param array $args Query arguments.
+	 * @return array Modified query arguments.
+	 */
+	public static function consolidate_customer_id_filters( $args ) {
+		$include_params = array( 'name_includes', 'email_includes', 'username_includes' );
+		$exclude_params = array( 'name_excludes', 'email_excludes', 'username_excludes' );
+		$match          = $args['match'] ?? 'all';
+
+		$include_sets = array();
+		foreach ( $include_params as $param ) {
+			if ( ! empty( $args[ $param ] ) && self::is_id_list( $args[ $param ] ) ) {
+				$include_sets[] = wp_parse_id_list( $args[ $param ] );
+				$args[ $param ] = null;
+			}
+		}
+		if ( ! empty( $include_sets ) ) {
+			$consolidated = count( $include_sets ) > 1
+				? ( 'all' === $match
+					? call_user_func_array( 'array_intersect', $include_sets )
+					: array_unique( array_merge( ...$include_sets ) ) )
+				: $include_sets[0];
+
+			// Merge with any pre-existing customers filter.
+			if ( ! empty( $args['customers'] ) ) {
+				$existing     = wp_parse_id_list( $args['customers'] );
+				$consolidated = 'all' === $match
+					? array_intersect( $consolidated, $existing )
+					: array_unique( array_merge( $consolidated, $existing ) );
+			}
+
+			// When match=all and intersection is empty, force no-results.
+			$args['customers'] = empty( $consolidated ) ? array( 0 ) : array_values( $consolidated );
+		}
+
+		$exclude_sets = array();
+		foreach ( $exclude_params as $param ) {
+			if ( ! empty( $args[ $param ] ) && self::is_id_list( $args[ $param ] ) ) {
+				$exclude_sets[] = wp_parse_id_list( $args[ $param ] );
+				$args[ $param ] = null;
+			}
+		}
+		if ( ! empty( $exclude_sets ) ) {
+			$consolidated = count( $exclude_sets ) > 1
+				? ( 'all' === $match
+					? array_unique( array_merge( ...$exclude_sets ) )
+					: call_user_func_array( 'array_intersect', $exclude_sets ) )
+				: $exclude_sets[0];
+
+			// Merge with any pre-existing customers_exclude filter.
+			if ( ! empty( $args['customers_exclude'] ) ) {
+				$existing     = wp_parse_id_list( $args['customers_exclude'] );
+				$consolidated = array_unique( array_merge( $consolidated, $existing ) );
+			}
+
+			$args['customers_exclude'] = array_values( $consolidated );
+		}
+
 		return $args;
 	}

+	/**
+	 * Check if a value is a comma-separated list of numeric IDs.
+	 *
+	 * @param mixed $value The value to check.
+	 * @return bool True if the value contains only numeric IDs.
+	 */
+	private static function is_id_list( $value ) {
+		if ( is_array( $value ) ) {
+			$values = $value;
+		} elseif ( is_string( $value ) ) {
+			$values = explode( ',', $value );
+		} else {
+			return false;
+		}
+		foreach ( $values as $v ) {
+			if ( ! is_numeric( trim( $v ) ) ) {
+				return false;
+			}
+		}
+		return true;
+	}
+
 	/**
 	 * Get one report.
 	 *
@@ -529,6 +625,15 @@ class Controller extends GenericController implements ExportableInterface {
 				'type' => 'integer',
 			),
 		);
+		$params['customers_exclude']       = array(
+			'description'       => __( 'Limit result to exclude items with specified customer ids.', 'woocommerce' ),
+			'type'              => 'array',
+			'sanitize_callback' => 'wp_parse_id_list',
+			'validate_callback' => 'rest_validate_request_arg',
+			'items'             => array(
+				'type' => 'integer',
+			),
+		);
 		$params['users']                   = array(
 			'description'       => __( 'Limit result to items with specified user ids.', 'woocommerce' ),
 			'type'              => 'array',
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
index 57e9772dd8d..b3a39e6d110 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
@@ -309,29 +309,25 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		$having_clauses = array();

 		$exact_match_params = array(
-			'name',
-			'username',
-			'email',
-			'country',
+			'name'     => "CONCAT_WS( ' ', {$customer_lookup_table}.first_name, {$customer_lookup_table}.last_name )",
+			'username' => "{$customer_lookup_table}.username",
+			'email'    => "{$customer_lookup_table}.email",
+			'country'  => "{$customer_lookup_table}.country",
 		);

-		foreach ( $exact_match_params as $exact_match_param ) {
+		foreach ( $exact_match_params as $exact_match_param => $column_expression ) {
 			if ( ! empty( $query_args[ $exact_match_param . '_includes' ] ) ) {
 				$exact_match_arguments         = $query_args[ $exact_match_param . '_includes' ];
 				$exact_match_arguments_escaped = array_map( 'esc_sql', explode( ',', $exact_match_arguments ) );
 				$included                      = implode( "','", $exact_match_arguments_escaped );
-				// 'country_includes' is a list of country codes, the others will be a list of customer ids.
-				$table_column    = 'country' === $exact_match_param ? $exact_match_param : 'customer_id';
-				$where_clauses[] = "{$customer_lookup_table}.{$table_column} IN ('{$included}')";
+				$where_clauses[]               = "{$column_expression} IN ('{$included}')";
 			}

 			if ( ! empty( $query_args[ $exact_match_param . '_excludes' ] ) ) {
 				$exact_match_arguments         = $query_args[ $exact_match_param . '_excludes' ];
 				$exact_match_arguments_escaped = array_map( 'esc_sql', explode( ',', $exact_match_arguments ) );
 				$excluded                      = implode( "','", $exact_match_arguments_escaped );
-				// 'country_includes' is a list of country codes, the others will be a list of customer ids.
-				$table_column    = 'country' === $exact_match_param ? $exact_match_param : 'customer_id';
-				$where_clauses[] = "{$customer_lookup_table}.{$table_column} NOT IN ('{$excluded}')";
+				$where_clauses[]               = "{$column_expression} NOT IN ('{$excluded}')";
 			}
 		}

@@ -386,6 +382,12 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			$where_clauses[]    = "{$customer_lookup_table}.customer_id IN ({$included_customers})";
 		}

+		// Allow a list of customer IDs to be excluded.
+		if ( ! empty( $query_args['customers_exclude'] ) ) {
+			$excluded_customers = $this->get_filtered_ids( $query_args, 'customers_exclude' );
+			$where_clauses[]    = "{$customer_lookup_table}.customer_id NOT IN ({$excluded_customers})";
+		}
+
 		// Allow a list of user IDs to be specified.
 		if ( ! empty( $query_args['users'] ) ) {
 			$included_users  = $this->get_filtered_ids( $query_args, 'users' );
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/Controller.php
index 3b1b201e50f..e99d685d2fd 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/Stats/Controller.php
@@ -8,6 +8,7 @@
 namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats;

 use Automattic\WooCommerce\Admin\API\Reports\Customers\Query;
+use Automattic\WooCommerce\Admin\API\Reports\Customers\Controller as CustomersController;

 defined( 'ABSPATH' ) || exit;

@@ -65,6 +66,7 @@ class Controller extends \WC_REST_Reports_Controller {
 		$args['last_order_before']   = $request['last_order_before'];
 		$args['last_order_after']    = $request['last_order_after'];
 		$args['customers']           = $request['customers'];
+		$args['customers_exclude']   = $request['customers_exclude'];
 		$args['fields']              = $request['fields'];
 		$args['force_cache_refresh'] = $request['force_cache_refresh'];

@@ -74,6 +76,8 @@ class Controller extends \WC_REST_Reports_Controller {
 		$normalized_params_date    = TimeInterval::normalize_between_params( $request, $between_params_date, true );
 		$args                      = array_merge( $args, $normalized_params_numeric, $normalized_params_date );

+		$args = CustomersController::consolidate_customer_id_filters( $args );
+
 		return $args;
 	}

@@ -380,6 +384,15 @@ class Controller extends \WC_REST_Reports_Controller {
 				'type' => 'integer',
 			),
 		);
+		$params['customers_exclude']       = array(
+			'description'       => __( 'Limit result to exclude items with specified customer ids.', 'woocommerce' ),
+			'type'              => 'array',
+			'sanitize_callback' => 'wp_parse_id_list',
+			'validate_callback' => 'rest_validate_request_arg',
+			'items'             => array(
+				'type' => 'integer',
+			),
+		);
 		$params['fields']                  = array(
 			'description'       => __( 'Limit stats fields to the specified items.', 'woocommerce' ),
 			'type'              => 'array',
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php
index 1961b5655d1..6d53c3667bc 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php
@@ -1,6 +1,7 @@
 <?php
 declare( strict_types=1 );

+use Automattic\WooCommerce\Admin\API\Reports\Customers\Controller as CustomersController;
 use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
 use Automattic\WooCommerce\Enums\OrderStatus;

@@ -639,4 +640,343 @@ class WC_Admin_Reports_Customers_Controller_Test extends WC_REST_Unit_Test_Case
 			$this->assertNotEquals( 'CA', $report['state'], 'No customers should be from CA state' );
 		}
 	}
+
+	/**
+	 * Test email_includes filters by email column.
+	 */
+	public function test_email_includes() {
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'email_includes' => 'customer1@example.com',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 1, $reports, 'Should return 1 customer matching the email' );
+		$this->assertEquals( 'customer1@example.com', $reports[0]['email'], 'Returned customer should have the matching email' );
+	}
+
+	/**
+	 * Test email_excludes filters by email column.
+	 */
+	public function test_email_excludes() {
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'email_excludes' => 'customer1@example.com,customer2@example.com,customer3@example.com',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 2, $reports, 'Should return 2 guest customers after excluding all registered customer emails' );
+		$emails = array_column( $reports, 'email' );
+		$this->assertNotContains( 'customer1@example.com', $emails, 'Excluded email should not appear' );
+		$this->assertNotContains( 'customer2@example.com', $emails, 'Excluded email should not appear' );
+		$this->assertNotContains( 'customer3@example.com', $emails, 'Excluded email should not appear' );
+	}
+
+	/**
+	 * Test username_includes filters by username column.
+	 */
+	public function test_username_includes() {
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'username_includes' => 'customer1',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 1, $reports, 'Should return 1 customer matching the username' );
+		$this->assertEquals( 'customer1', $reports[0]['username'], 'Returned customer should have the matching username' );
+	}
+
+	/**
+	 * Test username_excludes filters by username column.
+	 */
+	public function test_username_excludes() {
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'username_excludes' => 'customer1,customer2',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$usernames = array_column( $reports, 'username' );
+		$this->assertNotContains( 'customer1', $usernames, 'Excluded username should not appear' );
+		$this->assertNotContains( 'customer2', $usernames, 'Excluded username should not appear' );
+		$this->assertGreaterThanOrEqual( 3, count( $reports ), 'Should return at least 3 customers (customer3 + 2 guests)' );
+	}
+
+	/**
+	 * Test name_includes filters by name column.
+	 */
+	public function test_name_includes() {
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'name_includes' => 'John Doe',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 1, $reports, 'Should return 1 customer matching the name' );
+		$this->assertEquals( 'John Doe', $reports[0]['name'], 'Returned customer should have the matching name' );
+	}
+
+	/**
+	 * Test name_excludes filters by name column.
+	 */
+	public function test_name_excludes() {
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'name_excludes' => 'John Doe,Jane Smith',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$names = array_column( $reports, 'name' );
+		$this->assertNotContains( 'John Doe', $names, 'Excluded name should not appear' );
+		$this->assertNotContains( 'Jane Smith', $names, 'Excluded name should not appear' );
+		$this->assertGreaterThanOrEqual( 3, count( $reports ), 'Should return at least 3 customers (Bob Johnson + 2 guests)' );
+	}
+
+	/**
+	 * Test country_includes filters by country column.
+	 */
+	public function test_country_includes() {
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'country_includes' => 'CA',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 1, $reports, 'Should return 1 customer from CA' );
+		$this->assertEquals( 'CA', $reports[0]['country'], 'Returned customer should be from CA' );
+	}
+
+	/**
+	 * Test country_excludes filters by country column.
+	 */
+	public function test_country_excludes() {
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'country_excludes' => 'US',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 1, $reports, 'Should return 1 customer not from US' );
+		$this->assertEquals( 'CA', $reports[0]['country'], 'Returned customer should be from CA' );
+	}
+
+	/**
+	 * @testdox Should consolidate numeric name_includes IDs into customers param.
+	 */
+	public function test_name_includes_with_customer_ids(): void {
+		$customer_id = CustomersDataStore::get_customer_id_by_user_id( $this->registered_customers[0]->get_id() );
+
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'name_includes' => (string) $customer_id,
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 1, $reports, 'Should return 1 customer matching the customer ID' );
+		$this->assertEquals( 'John Doe', $reports[0]['name'], 'Returned customer should be John Doe' );
+	}
+
+	/**
+	 * @testdox Should consolidate numeric email_includes IDs into customers param.
+	 */
+	public function test_email_includes_with_customer_ids(): void {
+		$customer_id_1 = CustomersDataStore::get_customer_id_by_user_id( $this->registered_customers[0]->get_id() );
+		$customer_id_2 = CustomersDataStore::get_customer_id_by_user_id( $this->registered_customers[1]->get_id() );
+
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'email_includes' => "{$customer_id_1},{$customer_id_2}",
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 2, $reports, 'Should return 2 customers matching the customer IDs' );
+	}
+
+	/**
+	 * @testdox Should filter by customers_exclude param.
+	 */
+	public function test_customers_exclude(): void {
+		$customer_id_1 = CustomersDataStore::get_customer_id_by_user_id( $this->registered_customers[0]->get_id() );
+		$customer_id_2 = CustomersDataStore::get_customer_id_by_user_id( $this->registered_customers[1]->get_id() );
+
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'customers_exclude' => array( $customer_id_1, $customer_id_2 ),
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$ids = array_column( $reports, 'id' );
+		$this->assertNotContains( $customer_id_1, $ids, 'Excluded customer ID should not appear' );
+		$this->assertNotContains( $customer_id_2, $ids, 'Excluded customer ID should not appear' );
+	}
+
+	/**
+	 * @testdox Should consolidate numeric exclude IDs into customers_exclude param.
+	 */
+	public function test_email_excludes_with_customer_ids(): void {
+		$customer_id = CustomersDataStore::get_customer_id_by_user_id( $this->registered_customers[0]->get_id() );
+
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'email_excludes' => (string) $customer_id,
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$ids = array_column( $reports, 'id' );
+		$this->assertNotContains( $customer_id, $ids, 'Customer excluded by ID should not appear' );
+	}
+
+	/**
+	 * @testdox Should intersect include sets when match=all.
+	 */
+	public function test_consolidation_match_all_intersection(): void {
+		$customer_id_1 = CustomersDataStore::get_customer_id_by_user_id( $this->registered_customers[0]->get_id() );
+		$customer_id_2 = CustomersDataStore::get_customer_id_by_user_id( $this->registered_customers[1]->get_id() );
+
+		$args = CustomersController::consolidate_customer_id_filters(
+			array(
+				'match'             => 'all',
+				'name_includes'     => "{$customer_id_1},{$customer_id_2}",
+				'email_includes'    => (string) $customer_id_1,
+				'username_includes' => null,
+				'name_excludes'     => null,
+				'email_excludes'    => null,
+				'username_excludes' => null,
+			)
+		);
+
+		$this->assertEquals( array( $customer_id_1 ), $args['customers'], 'match=all should intersect include sets' );
+		$this->assertNull( $args['name_includes'], 'Consolidated param should be nulled' );
+		$this->assertNull( $args['email_includes'], 'Consolidated param should be nulled' );
+	}
+
+	/**
+	 * @testdox Should union include sets when match=any.
+	 */
+	public function test_consolidation_match_any_union(): void {
+		$customer_id_1 = CustomersDataStore::get_customer_id_by_user_id( $this->registered_customers[0]->get_id() );
+		$customer_id_2 = CustomersDataStore::get_customer_id_by_user_id( $this->registered_customers[1]->get_id() );
+
+		$args = CustomersController::consolidate_customer_id_filters(
+			array(
+				'match'             => 'any',
+				'name_includes'     => (string) $customer_id_1,
+				'email_includes'    => (string) $customer_id_2,
+				'username_includes' => null,
+				'name_excludes'     => null,
+				'email_excludes'    => null,
+				'username_excludes' => null,
+			)
+		);
+
+		$customers = $args['customers'];
+		sort( $customers );
+		$expected = array( $customer_id_1, $customer_id_2 );
+		sort( $expected );
+		$this->assertEquals( $expected, $customers, 'match=any should union include sets' );
+	}
+
+	/**
+	 * @testdox Should union exclude sets when match=all.
+	 */
+	public function test_consolidation_excludes_match_all(): void {
+		$args = CustomersController::consolidate_customer_id_filters(
+			array(
+				'match'             => 'all',
+				'name_includes'     => null,
+				'email_includes'    => null,
+				'username_includes' => null,
+				'name_excludes'     => '1,2',
+				'email_excludes'    => '2,3',
+				'username_excludes' => null,
+			)
+		);
+
+		$excluded = $args['customers_exclude'];
+		sort( $excluded );
+		$this->assertEquals( array( 1, 2, 3 ), $excluded, 'match=all should union exclude sets' );
+	}
+
+	/**
+	 * @testdox Should not consolidate non-numeric string values.
+	 */
+	public function test_string_values_not_consolidated(): void {
+		$args = CustomersController::consolidate_customer_id_filters(
+			array(
+				'match'             => 'all',
+				'name_includes'     => 'John Doe',
+				'email_includes'    => 'customer1@example.com',
+				'username_includes' => null,
+				'name_excludes'     => 'Jane Smith',
+				'email_excludes'    => null,
+				'username_excludes' => null,
+			)
+		);
+
+		$this->assertEquals( 'John Doe', $args['name_includes'], 'String name should not be consolidated' );
+		$this->assertEquals( 'customer1@example.com', $args['email_includes'], 'String email should not be consolidated' );
+		$this->assertEquals( 'Jane Smith', $args['name_excludes'], 'String name_excludes should not be consolidated' );
+		$this->assertArrayNotHasKey( 'customers', $args, 'customers should not be set for non-numeric values' );
+		$this->assertArrayNotHasKey( 'customers_exclude', $args, 'customers_exclude should not be set for non-numeric values' );
+	}
 }