Commit a243397092 for woocommerce
commit a2433970927120d66b359b71b5f5b71c59e06d6c
Author: Mike Jolley <mike.jolley@me.com>
Date: Fri Dec 12 16:10:48 2025 +0000
Rest API - Update analytics customer reports routes to support additional filtering and sorting options (#62267)
* Add user_type param to customer report API, w/ test coverage
* Include first and last name in response
* Allow sorting by first name, last name, email
* Trim whitespace from names
* Sort by location (state and country)
* location filtering and tests
* Rename customer to registered
* Add phone field to customer lookup table
* Remove state_includes
* reduce diff
* changelog
* update comments in tests
* update schema test due to new fields
* Anonymize phone field
* Remove phone field
* update changelog
* remove phone from schema
* remove phone from tests
* Apply CI feedback
* Fix get_customer_order_data_and_format
* Add return type to add_order_by_sql_params
* Protect against empty where clause
* Fix concat usage
* set default orderby in add_order_by_sql_params
* Fix aggregated field query missing table name
diff --git a/plugins/woocommerce/changelog/wooprd-1297-improvements-needed-for-customer-reports-route-and-lookup b/plugins/woocommerce/changelog/wooprd-1297-improvements-needed-for-customer-reports-route-and-lookup
new file mode 100644
index 0000000000..5364c0de8e
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooprd-1297-improvements-needed-for-customer-reports-route-and-lookup
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Updated wc-analytics/customers API route to include location filtering (country and state), and user type filtering (registered or guest)
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
index 56861a82c9..0d61292905 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
@@ -87,6 +87,9 @@ class Controller extends GenericController implements ExportableInterface {
$args['users'] = $request['users'];
$args['force_cache_refresh'] = $request['force_cache_refresh'];
$args['filter_empty'] = $request['filter_empty'];
+ $args['user_type'] = $request['user_type'];
+ $args['location_includes'] = $request['location_includes'];
+ $args['location_excludes'] = $request['location_excludes'];
$between_params_numeric = array( 'orders_count', 'total_spend', 'avg_order_value' );
$normalized_params_numeric = TimeInterval::normalize_between_params( $request, $between_params_numeric, false );
@@ -133,6 +136,8 @@ class Controller extends GenericController implements ExportableInterface {
public function prepare_item_for_response( $report, $request ) {
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $report, $request );
+ // Trim name field to prevent whitespace issues.
+ $data['name'] = trim( $data['name'] );
// Registered date is UTC.
$data['date_registered_gmt'] = wc_rest_prepare_date_response( $data['date_registered'] );
$data['date_registered'] = wc_rest_prepare_date_response( $data['date_registered'], false );
@@ -207,6 +212,24 @@ class Controller extends GenericController implements ExportableInterface {
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
+ 'first_name' => array(
+ 'description' => __( 'First name.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'last_name' => array(
+ 'description' => __( 'Last name.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'email' => array(
+ 'description' => __( 'Email address.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
'username' => array(
'description' => __( 'Username.', 'woocommerce' ),
'type' => 'string',
@@ -308,6 +331,10 @@ class Controller extends GenericController implements ExportableInterface {
array(
'username',
'name',
+ 'first_name',
+ 'last_name',
+ 'email',
+ 'location',
'country',
'city',
'state',
@@ -527,7 +554,27 @@ class Controller extends GenericController implements ExportableInterface {
),
),
);
-
+ $params['user_type'] = array(
+ 'description' => __( 'Limit result to items with specified user type.', 'woocommerce' ),
+ 'type' => 'string',
+ 'default' => 'all',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'enum' => array(
+ 'all',
+ 'registered',
+ 'guest',
+ ),
+ );
+ $params['location_includes'] = array(
+ 'description' => __( 'Includes customers by location (state, country). Provide a comma-separated list of locations. Each location can be a country code (e.g. GB) or combination of country and state (e.g. US:CA).', 'woocommerce' ),
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+ $params['location_excludes'] = array(
+ 'description' => __( 'Excludes customers by location (state, country). Provide a comma-separated list of locations. Each location can be a country code (e.g. GB) or combination of country and state (e.g. US:CA).', 'woocommerce' ),
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
return $params;
}
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
index 7c711d56aa..a60857162f 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
@@ -76,6 +76,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
'user_id' => 'user_id',
'username' => 'username',
'name' => "CONCAT_WS( ' ', first_name, last_name ) as name", // @xxx: What does this mean for RTL?
+ 'first_name' => 'first_name',
+ 'last_name' => 'last_name',
'email' => 'email',
'country' => 'country',
'city' => 'city',
@@ -176,20 +178,45 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
return 1 === $result;
}
+ /**
+ * Fills ORDER BY clause of SQL request based on user supplied parameters. Overridden here to allow multiple direction
+ * clauses.
+ *
+ * @since 10.5.0
+ * @param array $query_args Parameters supplied by the user.
+ * @return void
+ */
+ protected function add_order_by_sql_params( $query_args ) {
+ $order_by_clause = $this->normalize_order_by_clause(
+ $query_args['orderby'] ?? 'date_registered',
+ $query_args['order'] ?? 'desc'
+ );
+ $this->clear_sql_clause( 'order_by' );
+ $this->add_sql_clause( 'order_by', $order_by_clause );
+ }
+
/**
* Maps ordering specified by the user to columns in the database/fields in the data.
*
- * @override ReportsDataStore::normalize_order_by()
+ * Handles both order_by and direction.
*
+ * @since 10.5.0
* @param string $order_by Sorting criterion.
+ * @param string $order Order direction.
* @return string
*/
- protected function normalize_order_by( $order_by ) {
- if ( 'name' === $order_by ) {
- return "CONCAT_WS( ' ', first_name, last_name )";
+ protected function normalize_order_by_clause( $order_by, $order = 'desc' ) {
+ $order_by = esc_sql( $order_by );
+ $order = strtolower( $order ) === 'asc' ? 'ASC' : 'DESC';
+ $order_by_clause = '';
+
+ if ( 'location' === $order_by ) {
+ $order_by_clause = "state {$order}, country {$order}";
+ } else {
+ $order_by_clause = "{$order_by} {$order}";
}
- return $order_by;
+ return $order_by_clause;
}
/**
@@ -365,6 +392,28 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$where_clauses[] = "{$customer_lookup_table}.user_id IN ({$included_users})";
}
+ // Allow a list of locations to be specified (includes).
+ if ( ! empty( $query_args['location_includes'] ) ) {
+ $location_clause = $this->build_location_filter_clause( $query_args['location_includes'], true );
+ if ( '' !== $location_clause ) {
+ $where_clauses[] = $location_clause;
+ }
+ }
+
+ // Allow a list of locations to be excluded.
+ if ( ! empty( $query_args['location_excludes'] ) ) {
+ $location_clause = $this->build_location_filter_clause( $query_args['location_excludes'], false );
+ if ( '' !== $location_clause ) {
+ $where_clauses[] = $location_clause;
+ }
+ }
+
+ // Filter by user type.
+ if ( ! empty( $query_args['user_type'] ) && 'all' !== $query_args['user_type'] ) {
+ $user_type = $query_args['user_type'];
+ $where_clauses[] = "{$customer_lookup_table}.user_id IS " . ( 'registered' === $user_type ? 'NOT NULL' : 'NULL' );
+ }
+
$numeric_params = array(
'orders_count' => array(
'column' => 'COUNT( order_id )',
@@ -520,9 +569,20 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$this->subquery->clear_sql_clause( 'select' );
$this->subquery->add_sql_clause( 'select', $selections );
// For aggregated fields, ensure deterministic ordering by including GROUP BY field.
- $order_by = $this->get_sql_clause( 'order_by' );
- if ( in_array( $order_by, array( 'orders_count', 'total_spend', 'avg_order_value' ), true ) ) {
- $this->subquery->add_sql_clause( 'order_by', $order_by . ', customer_id' );
+ $order_by = $this->get_sql_clause( 'order_by' );
+ $aggregated_fields = array( 'orders_count', 'total_spend', 'avg_order_value' );
+ $has_aggregated_field = false;
+
+ foreach ( $aggregated_fields as $field ) {
+ if ( false !== strpos( $order_by, $field ) ) {
+ $has_aggregated_field = true;
+ break;
+ }
+ }
+
+ if ( $has_aggregated_field ) {
+ $customer_lookup_table = self::get_db_table_name();
+ $this->subquery->add_sql_clause( 'order_by', $order_by . ", {$customer_lookup_table}.customer_id" );
} else {
$this->subquery->add_sql_clause( 'order_by', $order_by );
}
@@ -835,6 +895,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
'%s',
'%s',
'%s',
+ '%s',
);
$customer_id = self::get_customer_id_by_user_id( $user_id );
@@ -1004,6 +1065,71 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
}
}
+ /**
+ * Build location filter SQL clause for includes or excludes.
+ *
+ * @since 10.5.0
+ * @param string $locations_string Comma-separated list of locations (e.g., "US:CA,US:NY,GB").
+ * @param bool $is_include True for IN clause, false for NOT IN clause.
+ * @return string SQL WHERE clause condition.
+ */
+ protected function build_location_filter_clause( $locations_string, $is_include = true ) {
+ $customer_lookup_table = self::get_db_table_name();
+ $locations_array = explode( ',', $locations_string );
+ $country_state_pairs = array();
+ $countries = array();
+
+ foreach ( $locations_array as $location ) {
+ $location = trim( $location );
+ if ( empty( $location ) ) {
+ continue;
+ }
+
+ if ( false !== strpos( $location, ':' ) ) {
+ $parts = explode( ':', $location );
+ if ( 2 === count( $parts ) ) {
+ $country_state_pairs[] = array(
+ 'country' => esc_sql( $parts[0] ),
+ 'state' => esc_sql( $parts[1] ),
+ );
+ }
+ } else {
+ $countries[] = esc_sql( $location );
+ }
+ }
+
+ $conditions = array();
+
+ // Build country:state pair conditions.
+ if ( ! empty( $country_state_pairs ) ) {
+ $pair_conditions = array();
+ foreach ( $country_state_pairs as $pair ) {
+ if ( $is_include ) {
+ $pair_conditions[] = "({$customer_lookup_table}.country = '{$pair['country']}' AND {$customer_lookup_table}.state = '{$pair['state']}')";
+ } else {
+ $pair_conditions[] = "({$customer_lookup_table}.country != '{$pair['country']}' OR {$customer_lookup_table}.state != '{$pair['state']}')";
+ }
+ }
+ $pair_connector = $is_include ? ' OR ' : ' AND ';
+ $conditions[] = '(' . implode( $pair_connector, $pair_conditions ) . ')';
+ }
+
+ // Build country-only conditions.
+ if ( ! empty( $countries ) ) {
+ $operator = $is_include ? 'IN' : 'NOT IN';
+ $conditions[] = "{$customer_lookup_table}.country {$operator} ('" . implode( "','", $countries ) . "')";
+ }
+
+ if ( empty( $conditions ) ) {
+ return '';
+ }
+
+ // Combine conditions with OR for includes, AND for excludes.
+ $connector = $is_include ? ' OR ' : ' AND ';
+
+ return '(' . implode( $connector, $conditions ) . ')';
+ }
+
/**
* Initialize query objects.
*/
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
index 6811da2b4f..a1358ea66a 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
@@ -64,6 +64,9 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
$this->assertArrayHasKey( 'id', $schema );
$this->assertArrayHasKey( 'user_id', $schema );
$this->assertArrayHasKey( 'name', $schema );
+ $this->assertArrayHasKey( 'first_name', $schema );
+ $this->assertArrayHasKey( 'last_name', $schema );
+ $this->assertArrayHasKey( 'email', $schema );
$this->assertArrayHasKey( 'username', $schema );
$this->assertArrayHasKey( 'country', $schema );
$this->assertArrayHasKey( 'city', $schema );
@@ -91,7 +94,7 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
$data = $response->get_data();
$properties = $data['schema']['properties'];
- $this->assertCount( 15, $properties );
+ $this->assertCount( 18, $properties );
$this->assert_report_item_schema( $properties );
}
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
new file mode 100644
index 0000000000..1961b5655d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php
@@ -0,0 +1,642 @@
+<?php
+declare( strict_types=1 );
+
+use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
+use Automattic\WooCommerce\Enums\OrderStatus;
+
+/**
+ * Reports Customers REST API Test Class
+ *
+ * @package WooCommerce\Admin\Tests\API
+ */
+class WC_Admin_Reports_Customers_Controller_Test extends WC_REST_Unit_Test_Case {
+ /**
+ * Endpoint.
+ *
+ * @var string
+ */
+ protected $endpoint = '/wc-analytics/reports/customers';
+
+ /**
+ * User ID for authentication.
+ *
+ * @var int
+ */
+ protected $user = 0;
+
+ /**
+ * Test product used for orders.
+ *
+ * @var WC_Product_Simple
+ */
+ protected $product;
+
+ /**
+ * Registered customers.
+ *
+ * @var array
+ */
+ protected $registered_customers = array();
+
+ /**
+ * Guest orders (no user_id).
+ *
+ * @var array
+ */
+ protected $guest_orders = array();
+
+ /**
+ * Setup test reports customers data.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->user = $this->factory->user->create(
+ array(
+ 'role' => 'administrator',
+ )
+ );
+
+ wp_set_current_user( $this->user );
+ WC_Helper_Reports::reset_stats_dbs();
+
+ // Create a test product.
+ $this->product = new WC_Product_Simple();
+ $this->product->set_name( 'Test Product' );
+ $this->product->set_regular_price( 25 );
+ $this->product->save();
+
+ // Create registered customers with different names for search testing.
+ $customer1 = WC_Helper_Customer::create_customer( 'customer1', 'password', 'customer1@example.com' );
+ $customer1->set_first_name( 'John' );
+ $customer1->set_last_name( 'Doe' );
+ $customer1->set_billing_state( 'CA' );
+ $customer1->set_billing_country( 'US' );
+ $customer1->save();
+ $this->registered_customers[] = $customer1;
+
+ $customer2 = WC_Helper_Customer::create_customer( 'customer2', 'password', 'customer2@example.com' );
+ $customer2->set_first_name( 'Jane' );
+ $customer2->set_last_name( 'Smith' );
+ $customer2->set_billing_state( 'NY' );
+ $customer2->set_billing_country( 'US' );
+ $customer2->save();
+ $this->registered_customers[] = $customer2;
+
+ $customer3 = WC_Helper_Customer::create_customer( 'customer3', 'password', 'customer3@example.com' );
+ $customer3->set_first_name( 'Bob' );
+ $customer3->set_last_name( 'Johnson' );
+ $customer3->set_billing_state( 'CA' );
+ $customer3->set_billing_country( 'US' );
+ $customer3->save();
+ $this->registered_customers[] = $customer3;
+
+ // Create orders for registered customers with location data.
+ foreach ( $this->registered_customers as $index => $customer ) {
+ $order = WC_Helper_Order::create_order( $customer->get_id(), $this->product );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->set_total( 100 + ( $index * 50 ) );
+ $order->set_billing_state( $customer->get_billing_state() );
+ $order->set_billing_country( $customer->get_billing_country() );
+ $order->save();
+ }
+
+ // Create guest orders (no user_id) with different locations.
+ $guest_order1 = WC_Helper_Order::create_order( 0, $this->product );
+ $guest_order1->set_billing_email( 'guest1@example.com' );
+ $guest_order1->set_billing_first_name( 'Guest' );
+ $guest_order1->set_billing_last_name( 'Customer' );
+ $guest_order1->set_billing_state( 'TX' );
+ $guest_order1->set_billing_country( 'US' );
+ $guest_order1->set_status( OrderStatus::COMPLETED );
+ $guest_order1->set_total( 50 );
+ $guest_order1->save();
+ $this->guest_orders[] = $guest_order1;
+
+ $guest_order2 = WC_Helper_Order::create_order( 0, $this->product );
+ $guest_order2->set_billing_email( 'guest2@example.com' );
+ $guest_order2->set_billing_first_name( 'Guest' );
+ $guest_order2->set_billing_last_name( 'User' );
+ $guest_order2->set_billing_state( 'ON' );
+ $guest_order2->set_billing_country( 'CA' );
+ $guest_order2->set_status( OrderStatus::COMPLETED );
+ $guest_order2->set_total( 75 );
+ $guest_order2->save();
+ $this->guest_orders[] = $guest_order2;
+
+ // Sync all data to lookup tables.
+ WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+ }
+
+ /**
+ * Test route registration.
+ */
+ public function test_register_routes() {
+ // This namespace may be lazy loaded, so we make a discovery request to trigger loading for this test.
+ $this->server->dispatch( new WP_REST_Request( 'GET', '/' ) );
+ $routes = $this->server->get_routes();
+
+ $this->assertArrayHasKey( $this->endpoint, $routes );
+ }
+
+ /**
+ * Test getting reports without valid permissions.
+ */
+ public function test_get_reports_without_permission() {
+ wp_set_current_user( 0 );
+ $response = $this->server->dispatch( new WP_REST_Request( 'GET', $this->endpoint ) );
+ $this->assertEquals( 401, $response->get_status() );
+ }
+
+ /**
+ * Test user_type parameter with 'all' value (default).
+ */
+ public function test_user_type_all() {
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'user_type' => 'all',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+ $headers = $response->get_headers();
+
+ $this->assertEquals( 200, $response->get_status() );
+ // Should return both registered and guest customers.
+ // We have 3 registered customers and 2 guest customers = 5 total.
+ $this->assertGreaterThanOrEqual( 5, count( $reports ) );
+ $this->assertArrayHasKey( 'X-WP-Total', $headers );
+ $this->assertGreaterThanOrEqual( 5, $headers['X-WP-Total'] );
+ }
+
+ /**
+ * Test user_type parameter with 'registered' value.
+ */
+ public function test_user_type_registered() {
+ // Test with user_type='registered'.
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'user_type' => 'registered',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+ $headers = $response->get_headers();
+
+ $this->assertEquals( 200, $response->get_status() );
+ // Should only return registered customers (with user_id).
+ // We have 3 registered customers.
+ $this->assertGreaterThanOrEqual( 3, count( $reports ) );
+ $this->assertArrayHasKey( 'X-WP-Total', $headers );
+ $this->assertGreaterThanOrEqual( 3, $headers['X-WP-Total'] );
+
+ // Verify all returned customers have a user_id.
+ foreach ( $reports as $report ) {
+ $this->assertNotNull( $report['user_id'], 'All customers should have a user_id when user_type=registered' );
+ }
+ }
+
+ /**
+ * Test user_type parameter with 'guest' value.
+ */
+ public function test_user_type_guest() {
+ // Test with user_type='guest'.
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'user_type' => 'guest',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+ $headers = $response->get_headers();
+
+ $this->assertEquals( 200, $response->get_status() );
+ // Should only return guest customers (without user_id).
+ // We have 2 guest customers.
+ $this->assertGreaterThanOrEqual( 2, count( $reports ) );
+ $this->assertArrayHasKey( 'X-WP-Total', $headers );
+ $this->assertGreaterThanOrEqual( 2, $headers['X-WP-Total'] );
+
+ // Verify all returned customers have user_id of 0 (intval converts NULL to 0).
+ foreach ( $reports as $report ) {
+ $this->assertEquals( 0, $report['user_id'], 'All customers should have user_id of 0 when user_type=guest' );
+ }
+ }
+
+ /**
+ * Test user_type parameter default behavior (when not specified).
+ */
+ public function test_user_type_default() {
+ // Test without specifying user_type (should default to 'all').
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+ $headers = $response->get_headers();
+
+ $this->assertEquals( 200, $response->get_status() );
+ // Should return both registered and guest customers (default behavior).
+ // We have 3 registered customers and 2 guest customers = 5 total.
+ $this->assertGreaterThanOrEqual( 5, count( $reports ) );
+ $this->assertArrayHasKey( 'X-WP-Total', $headers );
+ $this->assertGreaterThanOrEqual( 5, $headers['X-WP-Total'] );
+ }
+
+ /**
+ * Test user_type parameter combined with other filters.
+ */
+ public function test_user_type_with_other_filters() {
+ // Test user_type='registered' combined with search.
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'user_type' => 'registered',
+ 'search' => 'John',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ // Should only return registered customers matching the search.
+ // We have one customer named "John Doe".
+ $this->assertGreaterThanOrEqual( 1, count( $reports ) );
+ foreach ( $reports as $report ) {
+ $this->assertNotNull( $report['user_id'], 'All customers should have a user_id when user_type=registered' );
+ }
+
+ // Verify the search actually filtered correctly.
+ $found_john = false;
+ foreach ( $reports as $report ) {
+ if ( false !== strpos( $report['name'], 'John' ) ) {
+ $found_john = true;
+ break;
+ }
+ }
+ $this->assertTrue( $found_john, 'Search should return customer named John' );
+ }
+
+ /**
+ * Test location ordering parameter (sorts by state, then country).
+ */
+ public function test_orderby_location() {
+ // Test with orderby='location' ascending.
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'orderby' => 'location',
+ 'order' => 'asc',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ // Should have at least 5 customers (3 registered + 2 guest).
+ $this->assertGreaterThanOrEqual( 5, count( $reports ) );
+
+ // Verify ordering: should sort by state first, then country.
+ // Expected order (ascending by state, then country):
+ // - CA, US (customer1).
+ // - CA, US (customer3).
+ // - NY, US (customer2).
+ // - ON, CA (guest2).
+ // - TX, US (guest1).
+ $previous_state = '';
+ $previous_country = '';
+ foreach ( $reports as $report ) {
+ $current_state = $report['state'] ?? '';
+ $current_country = $report['country'] ?? '';
+
+ // If we have a previous entry, verify ordering.
+ if ( '' !== $previous_state ) {
+ // State should be >= previous state (ascending).
+ // If states are equal, country should be >= previous country.
+ $state_comparison = strcmp( $current_state, $previous_state );
+ if ( 0 === $state_comparison ) {
+ // States are equal, so country should be >= (ascending).
+ $this->assertGreaterThanOrEqual(
+ 0,
+ strcmp( $current_country, $previous_country ),
+ "When states are equal ({$current_state}), countries should be in ascending order. Previous: {$previous_country}, Current: {$current_country}"
+ );
+ } else {
+ // States are different, current should be >= previous (ascending).
+ $this->assertGreaterThanOrEqual(
+ 0,
+ $state_comparison,
+ "States should be in ascending order. Previous: {$previous_state}, Current: {$current_state}"
+ );
+ }
+ }
+
+ $previous_state = $current_state;
+ $previous_country = $current_country;
+ }
+
+ // Test with orderby='location' descending.
+ $request->set_query_params(
+ array(
+ 'orderby' => 'location',
+ 'order' => 'desc',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertGreaterThanOrEqual( 5, count( $reports ) );
+
+ // Verify descending ordering.
+ $previous_state = '';
+ $previous_country = '';
+ foreach ( $reports as $report ) {
+ $current_state = $report['state'] ?? '';
+ $current_country = $report['country'] ?? '';
+
+ if ( '' !== $previous_state ) {
+ $state_comparison = strcmp( $current_state, $previous_state );
+ if ( 0 === $state_comparison ) {
+ // States are equal, so country should be <= (descending).
+ $this->assertLessThanOrEqual(
+ 0,
+ strcmp( $current_country, $previous_country ),
+ "When states are equal ({$current_state}), countries should be in descending order. Previous: {$previous_country}, Current: {$current_country}"
+ );
+ } else {
+ // States are different, current should be <= previous (descending).
+ $this->assertLessThanOrEqual(
+ 0,
+ $state_comparison,
+ "States should be in descending order. Previous: {$previous_state}, Current: {$current_state}"
+ );
+ }
+ }
+
+ $previous_state = $current_state;
+ $previous_country = $current_country;
+ }
+ }
+
+ /**
+ * Test location_includes parameter with country:state format.
+ */
+ public function test_location_includes_country_state() {
+ // Test with location_includes='US:CA' (should return 2 customers: customer1 and customer3).
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'location_includes' => 'US:CA',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 2, $reports, 'Should return 2 customers from US:CA' );
+ foreach ( $reports as $report ) {
+ $this->assertEquals( 'US', $report['country'], 'All customers should be from US' );
+ $this->assertEquals( 'CA', $report['state'], 'All customers should be from CA' );
+ }
+
+ // Test with location_includes='US:NY' (should return 1 customer: customer2).
+ $request->set_query_params(
+ array(
+ 'location_includes' => 'US:NY',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 1, $reports, 'Should return 1 customer from US:NY' );
+ $this->assertEquals( 'US', $reports[0]['country'] );
+ $this->assertEquals( 'NY', $reports[0]['state'] );
+
+ // Test with location_includes='CA:ON' (should return 1 customer: guest2).
+ $request->set_query_params(
+ array(
+ 'location_includes' => 'CA:ON',
+ )
+ );
+
+ $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:ON' );
+ $this->assertEquals( 'CA', $reports[0]['country'] );
+ $this->assertEquals( 'ON', $reports[0]['state'] );
+ }
+
+ /**
+ * Test location_includes parameter with country format.
+ */
+ public function test_location_includes_country() {
+ // Test with location_includes='US' (should return 4 customers: customer1, customer2, customer3, guest1).
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'location_includes' => 'US',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 4, $reports, 'Should return 4 customers from US' );
+ foreach ( $reports as $report ) {
+ $this->assertEquals( 'US', $report['country'], 'All customers should be from US' );
+ }
+
+ // Test with location_includes='CA' (should return 1 customer: guest2).
+ $request->set_query_params(
+ array(
+ 'location_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'] );
+ }
+
+ /**
+ * Test location_includes parameter with mixed formats.
+ */
+ public function test_location_includes_mixed() {
+ // Test with location_includes='US:CA,US:NY' (should return 3 customers: customer1, customer2, customer3).
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'location_includes' => 'US:CA,US:NY',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 3, $reports, 'Should return 3 customers from US:CA and US:NY' );
+
+ // Test with location_includes='US:CA,CA' (should return 3 customers: customer1, customer3, guest2).
+ $request->set_query_params(
+ array(
+ 'location_includes' => 'US:CA,CA',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 3, $reports, 'Should return 3 customers from US:CA and CA country' );
+ }
+
+ /**
+ * Test location_excludes parameter with country:state format.
+ */
+ public function test_location_excludes_country_state() {
+ // Test with location_excludes='US:CA' (should exclude 2 customers, return 3: customer2, guest1, guest2).
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'location_excludes' => 'US:CA',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 3, $reports, 'Should return 3 customers excluding US:CA' );
+ foreach ( $reports as $report ) {
+ $location = $report['country'] . ':' . $report['state'];
+ $this->assertNotEquals( 'US:CA', $location, 'No customers should be from US:CA' );
+ }
+
+ // Test with location_excludes='US:TX' (should exclude 1 customer, return 4: customer1, customer2, customer3, guest2).
+ $request->set_query_params(
+ array(
+ 'location_excludes' => 'US:TX',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 4, $reports, 'Should return 4 customers excluding US:TX' );
+ }
+
+ /**
+ * Test location_excludes parameter with country format.
+ */
+ public function test_location_excludes_country() {
+ // Test with location_excludes='US' (should exclude 4 customers, return 1: guest2).
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'location_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 excluding US' );
+ $this->assertEquals( 'CA', $reports[0]['country'], 'Remaining customer should be from CA' );
+
+ // Test with location_excludes='CA' (should exclude 1 customer, return 4: customer1, customer2, customer3, guest1).
+ $request->set_query_params(
+ array(
+ 'location_excludes' => 'CA',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 4, $reports, 'Should return 4 customers excluding CA' );
+ foreach ( $reports as $report ) {
+ $this->assertEquals( 'US', $report['country'], 'All customers should be from US' );
+ }
+ }
+
+ /**
+ * Test location_excludes parameter with mixed formats.
+ */
+ public function test_location_excludes_mixed() {
+ // Test with location_excludes='US:CA,US:TX' (should exclude 3 customers, return 2: customer2, guest2).
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'location_excludes' => 'US:CA,US:TX',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 2, $reports, 'Should return 2 customers excluding US:CA and US:TX' );
+
+ // Test with location_excludes='US:CA,CA' (should exclude 3 customers, return 2: customer2, guest1).
+ $request->set_query_params(
+ array(
+ 'location_excludes' => 'US:CA,CA',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 2, $reports, 'Should return 2 customers excluding US:CA and CA country' );
+ foreach ( $reports as $report ) {
+ $this->assertEquals( 'US', $report['country'], 'All remaining customers should be from US' );
+ $this->assertNotEquals( 'CA', $report['state'], 'No customers should be from CA state' );
+ }
+ }
+
+ /**
+ * Test location_includes and location_excludes combined.
+ */
+ public function test_location_includes_and_excludes() {
+ // Test with location_includes='US' and location_excludes='US:CA' (should return 2: customer2, guest1).
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'location_includes' => 'US',
+ 'location_excludes' => 'US:CA',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 2, $reports, 'Should return 2 customers from US excluding US:CA' );
+ foreach ( $reports as $report ) {
+ $this->assertEquals( 'US', $report['country'], 'All customers should be from US' );
+ $this->assertNotEquals( 'CA', $report['state'], 'No customers should be from CA state' );
+ }
+ }
+}