Commit 9ce1feeb81c for woocommerce

commit 9ce1feeb81c8681470e2a66a59aced96427f356b
Author: Valery Sukhomlinov <683297+dmvrtx@users.noreply.github.com>
Date:   Tue Oct 27 11:42:26 2026 +0300

    Align `wc_get_orders()` behaviour between CPT and HPOS for unknown order statuses. (#68590)

    Closes #27238.

    During the investigation of the report in #27238 I created a test case for both HPOS and CPT order data stores. It highlighted the discrepancy between handling the following scenario.

     - One plugin registers custom order statuses.
     - This plugin is later disabled.
     - The merchant attempts to filter orders with those custom statuses.
       - HPOS search returns `0` orders when the status is passed without the `wc-` prefix (e.g. `foobar`).
       - CPT search returns a list of orders that satisfy other query criteria.

    This happens because CPT delegates the last mile to `WP_Query`. `WP_Query` does its own check of the post/order statuses and includes only those that are _available right now_ into the `WHERE` statement. Therefore, if the query contains a single filter by the status provided by the disabled plugin, there is no filtering at all, and all orders are returned.

    This behavior cannot be properly fixed on the `WP_Query` level without possible ripple side effects. However, it is possible to fix it for the specific scenario with statuses on the WooCommerce side.

    This change does this:

     - Adds test cases to avoid further regressions in this flow.
     - Normalizes requested statuses the same way in both data stores: a string is split on commas, each status is trimmed and lowercased, non-string values (including objects) are dropped, and the `wc-` prefix is added if required. On both stores, `'all'` in a list takes precedence over the other statuses, unless `'any'` is also present.
     - Adds a check in `WC_Order_Data_Store_CPT::query()`, run on the final `WP_Query` args (after any changes done by filters).
       - If none of the requested statuses are known, the query returns an empty result instead of reaching `WP_Query`. A status counts as known if it is registered. Until `init` registers any of the statuses from `wc_get_order_statuses()`, those count as known too, so early queries (e.g., during upgrades) keep working. `any/all` filters remain working as before.
     - On HPOS, if no usable status is left (e.g. only `'0'`, empty strings, or non-strings), the query matches no orders (`1=0`) instead of dropping the status condition. `all` still skips status filtering.
     - Fixes the legacy Customer List report, which passed translated status labels instead of status slugs to `wc_get_orders()`. Before, on CPT the "Last order" column and the "Link previous orders" check matched orders of any status (WP_Query dropped the unknown labels), and on HPOS they never matched. With the new check, CPT stores would also stop matching. Now "Last order" shows the newest paid order, and "Link previous orders" is offered for guest orders of any status, the same ones `wc_update_new_customer_past_orders()` links.

diff --git a/docs/features/orders/wc-get-orders.md b/docs/features/orders/wc-get-orders.md
index 9dfed7fa19e..5369ff8ec67 100644
--- a/docs/features/orders/wc-get-orders.md
+++ b/docs/features/orders/wc-get-orders.md
@@ -75,7 +75,7 @@ Query parameters/arguments that can be used with these functions are described b

 | Parameter | Description |
 | --- | --- |
-| **status** | Accepts an array of strings: by default is set to the keys of `wc_get_order_statuses()`. Both `wc-`-prefixed (`'wc-processing'`) and unprefixed (`'processing'`) values are accepted. See the [OrderInternalStatus](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Enums/OrderInternalStatus.php) and [OrderStatus](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Enums/OrderStatus.php) constant classes. |
+| **status** | Accepts a string, a comma-separated string, or an array of strings: by default is set to the keys of `wc_get_order_statuses()`. Both `wc-`-prefixed (`'wc-processing'`) and unprefixed (`'processing'`) values are accepted. Values are trimmed and matched case-insensitively. If none of the requested statuses are known, no orders are returned. With HPOS, a `wc-`-prefixed status that is no longer registered still matches orders stored with it. Use `'any'` for all statuses except those excluded from search (such as `checkout-draft`), or `'all'` for no status filter; `'all'` takes precedence over any other status in the list except `'any'`. See the [OrderInternalStatus](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Enums/OrderInternalStatus.php) and [OrderStatus](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Enums/OrderStatus.php) constant classes. |
 | **type** | Accepts a string: `'shop_order'`, `'shop_order_refund'`, or a custom order type. |
 | **version** | Accepts a string: WooCommerce version number the order was created in. |
 | **created_via** | Accepts a string: 'checkout', 'rest-api', or a custom creation method slug. |
diff --git a/plugins/woocommerce/changelog/fix-customer-list-report-order-statuses b/plugins/woocommerce/changelog/fix-customer-list-report-order-statuses
new file mode 100644
index 00000000000..6a7ac869b98
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-customer-list-report-order-statuses
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show the latest paid order and the "Link previous orders" button correctly in the legacy Customer List report.
diff --git a/plugins/woocommerce/changelog/wooplug-212-wc_get_orders-returns-all-orders-if-the-status-being b/plugins/woocommerce/changelog/wooplug-212-wc_get_orders-returns-all-orders-if-the-status-being
new file mode 100644
index 00000000000..72df3ad6206
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-212-wc_get_orders-returns-all-orders-if-the-status-being
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Return no orders, instead of all of them, when filtering by an order status that no longer exists.
diff --git a/plugins/woocommerce/includes/admin/reports/class-wc-report-customer-list.php b/plugins/woocommerce/includes/admin/reports/class-wc-report-customer-list.php
index 45e413f0a23..7c181f3072b 100644
--- a/plugins/woocommerce/includes/admin/reports/class-wc-report-customer-list.php
+++ b/plugins/woocommerce/includes/admin/reports/class-wc-report-customer-list.php
@@ -130,7 +130,7 @@ class WC_Report_Customer_List extends WP_List_Table {
 				$orders = wc_get_orders(
 					array(
 						'limit'    => 1,
-						'status'   => array_map( 'wc_get_order_status_name', wc_get_is_paid_statuses() ),
+						'status'   => wc_get_is_paid_statuses(),
 						'customer' => $user->ID,
 					)
 				);
@@ -173,7 +173,6 @@ class WC_Report_Customer_List extends WP_List_Table {
 					$orders = wc_get_orders(
 						array(
 							'limit'    => 1,
-							'status'   => array_map( 'wc_get_order_status_name', wc_get_is_paid_statuses() ),
 							'customer' => array( array( 0, $user->user_email ) ),
 						)
 					);
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php
index f90c364d1ad..3dfaaa9cefe 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php
@@ -941,12 +941,21 @@ class WC_Order_Data_Store_CPT extends Abstract_WC_Order_Data_Store_CPT implement

 		// Add the 'wc-' prefix to status if needed.
 		if ( ! empty( $query_vars['post_status'] ) ) {
-			if ( is_array( $query_vars['post_status'] ) ) {
-				foreach ( $query_vars['post_status'] as &$status ) {
-					$status = wc_is_order_status( 'wc-' . $status ) ? 'wc-' . $status : $status;
-				}
-			} else {
-				$query_vars['post_status'] = wc_is_order_status( 'wc-' . $query_vars['post_status'] ) ? 'wc-' . $query_vars['post_status'] : $query_vars['post_status'];
+			$statuses = is_string( $query_vars['post_status'] )
+				? explode( ',', $query_vars['post_status'] )
+				: ( is_array( $query_vars['post_status'] ) ? $query_vars['post_status'] : array( $query_vars['post_status'] ) );
+
+			$order_statuses            = array_keys( wc_get_order_statuses() );
+			$query_vars['post_status'] = array();
+			foreach ( array_filter( $statuses, 'is_string' ) as $status ) {
+				// Lowercase here, so an uppercase status still gets the 'wc-' prefix.
+				$status                      = strtolower( trim( $status ) );
+				$query_vars['post_status'][] = in_array( 'wc-' . $status, $order_statuses, true ) ? 'wc-' . $status : $status;
+			}
+
+			// WP_Query ignores 'all' and keeps the other statuses, while HPOS drops the status filter. Match HPOS.
+			if ( in_array( 'all', $query_vars['post_status'], true ) && ! in_array( 'any', $query_vars['post_status'], true ) ) {
+				$query_vars['post_status'] = array( 'all' );
 			}
 		}

@@ -1097,7 +1106,7 @@ class WC_Order_Data_Store_CPT extends Abstract_WC_Order_Data_Store_CPT implement

 		$args = $this->get_wp_query_args( $query_vars );

-		if ( ! empty( $args['errors'] ) ) {
+		if ( ! empty( $args['errors'] ) || ( is_array( $args ) && $this->has_only_unknown_statuses( $args ) ) ) {
 			$query = (object) array(
 				'posts'         => array(),
 				'found_posts'   => 0,
@@ -1126,6 +1135,37 @@ class WC_Order_Data_Store_CPT extends Abstract_WC_Order_Data_Store_CPT implement
 		return $orders;
 	}

+	/**
+	 * Check whether none of the requested statuses are known. WP_Query drops the status clause in that case, which would
+	 * return every order. Runs on the final query args, after query filters. Until any wc_get_order_statuses() status is
+	 * registered on `init`, all of them count as known, so early queries (e.g. during upgrades) still find orders.
+	 *
+	 * @param array $wp_query_args Final WP_Query args.
+	 * @return bool
+	 */
+	private function has_only_unknown_statuses( array $wp_query_args ): bool {
+		$post_status = $wp_query_args['post_status'] ?? null;
+
+		// Without a status (including '0'), WP_Query applies its default statuses, which never match an order status.
+		if ( empty( $post_status ) ) {
+			return false;
+		}
+
+		// Normalize like WP_Query::parse_query(): arrays go through sanitize_key(), strings lose disallowed characters (including uppercase).
+		$requested_statuses = is_array( $post_status )
+			? array_map( 'sanitize_key', $post_status )
+			: explode( ',', (string) preg_replace( '|[^a-z0-9_,-]|', '', is_scalar( $post_status ) ? (string) $post_status : '' ) );
+
+		$registered_statuses = get_post_stati();
+		$order_statuses      = array_keys( wc_get_order_statuses() );
+		$known_statuses      = array_merge( array( 'any', 'all' ), $registered_statuses );
+		if ( ! array_intersect( $order_statuses, $registered_statuses ) ) {
+			$known_statuses = array_merge( $known_statuses, $order_statuses );
+		}
+
+		return ! array_intersect( $requested_statuses, $known_statuses );
+	}
+
 	/**
 	 * Compile order response and set caches as needed for order ids.
 	 *
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php
index 214233e19b8..857ef201559 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php
@@ -696,30 +696,39 @@ class OrdersTableQuery {
 	 */
 	private function sanitize_status(): void {
 		$valid_statuses = array_keys( wc_get_order_statuses() );
+		$status_arg     = $this->args['status'] ?? array();

-		if ( empty( $this->args['status'] ) ) {
-			$this->args['status'] = array();
+		// A supplied '0' is not a missing status; it falls through to the no-match check below.
+		if ( empty( $status_arg ) && '0' !== $status_arg ) {
+			$status_arg = array( 'any' );
 		}

-		if ( ! is_array( $this->args['status'] ) ) {
-			$this->args['status'] = array( $this->args['status'] );
-		}
+		// Only strings are accepted as statuses.
+		$statuses = is_array( $status_arg ) ? $status_arg : ( is_string( $status_arg ) ? explode( ',', $status_arg ) : array() );
+		$statuses = array_map( 'strtolower', array_map( 'trim', array_filter( $statuses, 'is_string' ) ) );

-		if ( empty( $this->args['status'] ) || in_array( 'any', $this->args['status'], true ) ) {
+		if ( in_array( 'any', $statuses, true ) ) {
 			// Querying for 'any' status or empty status, filter to valid statuses from wc_get_order_statuses(),
 			// excluding statuses marked as exclude_from_search (e.g. checkout-draft) to match WP_Query behavior.
-			$exclude              = get_post_stati( array( 'exclude_from_search' => true ) );
-			$this->args['status'] = array_diff( $valid_statuses, $exclude );
-		} elseif ( in_array( 'all', $this->args['status'], true ) ) {
+			$exclude  = get_post_stati( array( 'exclude_from_search' => true ) );
+			$statuses = array_diff( $valid_statuses, $exclude );
+		} elseif ( in_array( 'all', $statuses, true ) ) {
 			// Querying for 'all' status does not filter by status at all.
 			$this->args['status'] = array();
+			return;
 		}

-		foreach ( $this->args['status'] as &$status ) {
+		foreach ( $statuses as &$status ) {
 			$status = in_array( 'wc-' . $status, $valid_statuses, true ) ? 'wc-' . $status : $status;
 		}
+		unset( $status );

-		$this->args['status'] = array_unique( array_filter( $this->args['status'] ) );
+		$this->args['status'] = array_unique( array_filter( $statuses ) );
+
+		// Nothing usable is left (e.g. only non-strings or '0' were given). Match no orders instead of skipping the status condition.
+		if ( empty( $this->args['status'] ) ) {
+			$this->where[] = '1=0';
+		}
 	}

 	/**
diff --git a/plugins/woocommerce/tests/legacy/bootstrap.php b/plugins/woocommerce/tests/legacy/bootstrap.php
index 1a9b9f6c104..9e257d95f47 100644
--- a/plugins/woocommerce/tests/legacy/bootstrap.php
+++ b/plugins/woocommerce/tests/legacy/bootstrap.php
@@ -357,6 +357,7 @@ class WC_Unit_Tests_Bootstrap {
 		require_once dirname( $this->tests_dir ) . '/php/helpers/MetaDataAssertionTrait.php';
 		require_once dirname( $this->tests_dir ) . '/php/helpers/CorePayPalGatewayTrait.php';
 		require_once dirname( $this->tests_dir ) . '/php/helpers/ImageAttachmentTrait.php';
+		require_once dirname( $this->tests_dir ) . '/php/helpers/InterleavedStatusOrdersTrait.php';
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/helpers/InterleavedStatusOrdersTrait.php b/plugins/woocommerce/tests/php/helpers/InterleavedStatusOrdersTrait.php
new file mode 100644
index 00000000000..84a2e5480c1
--- /dev/null
+++ b/plugins/woocommerce/tests/php/helpers/InterleavedStatusOrdersTrait.php
@@ -0,0 +1,35 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Helpers;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+
+/**
+ * Trait InterleavedStatusOrdersTrait.
+ *
+ * Creates orders with known statuses and creation dates, for tests that filter or sort orders by status.
+ */
+trait InterleavedStatusOrdersTrait {
+
+	/**
+	 * Creates orders cycling through pending, processing and completed statuses, with strictly decreasing creation dates.
+	 *
+	 * @param int $count Number of orders to create.
+	 * @return int[] IDs of the created orders, newest first.
+	 */
+	private function create_orders_with_interleaved_statuses( int $count ): array {
+		$statuses = array( OrderStatus::PENDING, OrderStatus::PROCESSING, OrderStatus::COMPLETED );
+		$ids      = array();
+
+		for ( $i = 0; $i < $count; $i++ ) {
+			$order = new \WC_Order();
+			$order->set_status( $statuses[ $i % count( $statuses ) ] );
+			$order->set_date_created( strtotime( '2023-06-01 12:00:00' ) - ( $i * HOUR_IN_SECONDS ) );
+			$order->save();
+			$ids[] = $order->get_id();
+		}
+
+		return $ids;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/admin/reports/class-wc-report-customer-list-test.php b/plugins/woocommerce/tests/php/includes/admin/reports/class-wc-report-customer-list-test.php
new file mode 100644
index 00000000000..4625147b714
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/reports/class-wc-report-customer-list-test.php
@@ -0,0 +1,132 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+
+/**
+ * Tests for the WC_Report_Customer_List class.
+ */
+class WC_Report_Customer_List_Test extends WC_Unit_Test_Case {
+	use HPOSToggleTrait;
+
+	/**
+	 * Whether HPOS was enabled before the test.
+	 *
+	 * @var bool
+	 */
+	private $previous_hpos_state;
+
+	/**
+	 * Ensure permanent HPOS tables exist before per-test transactions start.
+	 */
+	public static function wpSetUpBeforeClass(): void {
+		self::setup_cot_tables();
+		require_once WC_ABSPATH . 'includes/admin/reports/class-wc-report-customer-list.php';
+	}
+
+	/**
+	 * Allow switching the order storage in each test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		remove_filter( 'query', array( $this, '_create_temporary_tables' ) );
+		remove_filter( 'query', array( $this, '_drop_temporary_tables' ) );
+		$this->previous_hpos_state = OrderUtil::custom_orders_table_usage_is_enabled();
+	}
+
+	/**
+	 * Restore the order storage.
+	 */
+	public function tearDown(): void {
+		try {
+			$this->toggle_cot_feature_and_usage( $this->previous_hpos_state );
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * @testdox The "Last order" column shows the newest paid order, also when order status labels are translated.
+	 *
+	 * @dataProvider order_storage_provider
+	 * @param bool $hpos_enabled Whether HPOS is enabled.
+	 */
+	public function test_last_order_column_shows_newest_paid_order( bool $hpos_enabled ): void {
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+		$customer_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		$paid_order  = $this->create_order( OrderStatus::COMPLETED, strtotime( '2023-06-01 10:00:00' ), $customer_id );
+		$this->create_order( OrderStatus::PENDING, strtotime( '2023-06-01 12:00:00' ), $customer_id );
+
+		add_filter(
+			'wc_order_statuses',
+			function ( $statuses ) {
+				return array_map(
+					function ( $label ) {
+						return 'Traduit ' . $label;
+					},
+					$statuses
+				);
+			}
+		);
+
+		$column = ( new WC_Report_Customer_List() )->column_default( get_user_by( 'id', $customer_id ), 'last_order' );
+
+		$this->assertStringContainsString( '#' . $paid_order->get_order_number() . '<', $column, 'The newest paid order should be shown, not a newer unpaid one.' );
+	}
+
+	/**
+	 * @testdox The "Link previous orders" action is shown for unlinked guest orders of any status.
+	 *
+	 * @dataProvider order_storage_provider
+	 * @param bool $hpos_enabled Whether HPOS is enabled.
+	 */
+	public function test_link_previous_orders_action_shown_for_unpaid_guest_order( bool $hpos_enabled ): void {
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+		$customer_id = $this->factory->user->create(
+			array(
+				'role'       => 'customer',
+				'user_email' => 'guest-buyer@example.com',
+			)
+		);
+		$guest_order = $this->create_order( OrderStatus::ON_HOLD, strtotime( '2023-06-01 10:00:00' ), 0 );
+		$guest_order->set_billing_email( 'guest-buyer@example.com' );
+		$guest_order->save();
+
+		$column = ( new WC_Report_Customer_List() )->column_default( get_user_by( 'id', $customer_id ), 'wc_actions' );
+
+		$this->assertStringContainsString( 'link_orders', $column, 'Guest orders are linked regardless of status, so the action should be offered for an unpaid one.' );
+	}
+
+	/**
+	 * Provides order storage configurations.
+	 *
+	 * @return array<string, array{bool}>
+	 */
+	public function order_storage_provider(): array {
+		return array(
+			'HPOS'       => array( true ),
+			'legacy CPT' => array( false ),
+		);
+	}
+
+	/**
+	 * Creates an order with the given status, creation date and customer.
+	 *
+	 * @param string $status      Order status.
+	 * @param int    $created     Creation timestamp.
+	 * @param int    $customer_id Customer ID, 0 for a guest order.
+	 * @return WC_Order
+	 */
+	private function create_order( string $status, int $created, int $customer_id ): WC_Order {
+		$order = new WC_Order();
+		$order->set_status( $status );
+		$order->set_customer_id( $customer_id );
+		$order->set_date_created( $created );
+		$order->save();
+
+		return $order;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
index 6abe72b993d..c9fe0c02e2b 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
@@ -1,9 +1,11 @@
 <?php

+use Automattic\WooCommerce\Enums\OrderInternalStatus;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
 use Automattic\WooCommerce\Utilities\OrderUtil;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
+use Automattic\WooCommerce\Tests\Helpers\InterleavedStatusOrdersTrait;

 //phpcs:disable Squiz.Classes.ClassFileName.NoMatch, Squiz.Classes.ValidClassName.NotCamelCaps -- Legacy class name.
 /**
@@ -13,6 +15,7 @@ use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
  */
 class WC_Order_Data_Store_CPT_Test extends WC_Unit_Test_Case {
 	use CogsAwareUnitTestSuiteTrait;
+	use InterleavedStatusOrdersTrait;

 	/**
 	 * Store the COT state before the test class.
@@ -1704,6 +1707,268 @@ class WC_Order_Data_Store_CPT_Test extends WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox CPT order queries return matching orders when the "$filter" filter replaces an unknown status with a registered one.
+	 *
+	 * @dataProvider cpt_query_args_filter_provider
+	 * @param string $filter Filter that receives the WP_Query args.
+	 */
+	public function test_cpt_order_queries_respect_filter_replacing_unknown_status_with_registered_one( string $filter ): void {
+		$order_ids = $this->create_orders_with_interleaved_statuses( 3 );
+
+		add_filter(
+			$filter,
+			function ( $wp_query_args ) {
+				$wp_query_args['post_status'] = array( OrderInternalStatus::COMPLETED );
+				return $wp_query_args;
+			}
+		);
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => 'foobar',
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array( $order_ids[2] ), $queried_order_ids, 'The status set by the filter should be validated instead of the original unknown one.' );
+	}
+
+	/**
+	 * @testdox CPT order queries return no orders when the "$filter" filter replaces a registered status with an unknown one.
+	 *
+	 * @dataProvider cpt_query_args_filter_provider
+	 * @param string $filter Filter that receives the WP_Query args.
+	 */
+	public function test_cpt_order_queries_return_empty_when_filter_replaces_registered_status_with_unknown_one( string $filter ): void {
+		$this->create_orders_with_interleaved_statuses( 3 );
+
+		add_filter(
+			$filter,
+			function ( $wp_query_args ) {
+				$wp_query_args['post_status'] = 'foobar';
+				return $wp_query_args;
+			}
+		);
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => OrderStatus::COMPLETED,
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array(), $queried_order_ids, 'An unknown status set by the filter should not make the query return every order.' );
+	}
+
+	/**
+	 * @testdox CPT order queries return no orders when the "$filter" filter sets an uppercase status string, which WP_Query cannot match.
+	 *
+	 * @dataProvider cpt_query_args_filter_provider
+	 * @param string $filter Filter that receives the WP_Query args.
+	 */
+	public function test_cpt_order_queries_return_empty_when_filter_sets_uppercase_status_string( string $filter ): void {
+		$this->create_orders_with_interleaved_statuses( 3 );
+
+		add_filter(
+			$filter,
+			function ( $wp_query_args ) {
+				$wp_query_args['post_status'] = 'WC-COMPLETED';
+				return $wp_query_args;
+			}
+		);
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => OrderStatus::COMPLETED,
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array(), $queried_order_ids, 'WP_Query strips uppercase letters from a status string, so the status should be validated as WP_Query sees it.' );
+	}
+
+	/**
+	 * @testdox CPT order queries return matching orders when a query filter sets a status that WP_Query normalizes to a registered one.
+	 *
+	 * @testWith [["WC-COMPLETED"]]
+	 *           ["wc-completed "]
+	 *
+	 * @param string|array $post_status Status value set by the filter.
+	 */
+	public function test_cpt_order_queries_validate_filtered_status_as_wp_query_normalizes_it( $post_status ): void {
+		$order_ids = $this->create_orders_with_interleaved_statuses( 3 );
+
+		add_filter(
+			'woocommerce_order_data_store_cpt_get_orders_query',
+			function ( $wp_query_args ) use ( $post_status ) {
+				$wp_query_args['post_status'] = $post_status;
+				return $wp_query_args;
+			}
+		);
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => 'foobar',
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array( $order_ids[2] ), $queried_order_ids, 'A status that WP_Query can match after its own normalization should not be treated as unknown.' );
+	}
+
+	/**
+	 * @testdox CPT order queries with default statuses still find orders before order statuses are registered, as during upgrades.
+	 */
+	public function test_cpt_order_queries_find_orders_before_order_statuses_are_registered(): void {
+		global $wp_post_statuses;
+
+		$order_ids = $this->create_orders_with_interleaved_statuses( 3 );
+
+		$registered_statuses = $wp_post_statuses;
+		foreach ( array_keys( wc_get_order_statuses() ) as $status ) {
+			unset( $wp_post_statuses[ $status ] );
+		}
+
+		try {
+			$queried_order_ids = wc_get_orders(
+				array(
+					'limit'  => -1,
+					'return' => 'ids',
+				)
+			);
+		} finally {
+			// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the global changed above.
+			$wp_post_statuses = $registered_statuses;
+		}
+
+		$this->assertEqualsCanonicalizing( $order_ids, $queried_order_ids, 'Default order statuses should count as known even before they are registered, so early queries such as the HPOS new-shop check still find orders.' );
+	}
+
+	/**
+	 * @testdox CPT order queries for an unknown status return no orders even before order statuses are registered.
+	 */
+	public function test_cpt_order_queries_return_empty_for_unknown_status_before_order_statuses_are_registered(): void {
+		global $wp_post_statuses;
+
+		$this->create_orders_with_interleaved_statuses( 3 );
+
+		$registered_statuses = $wp_post_statuses;
+		foreach ( array_keys( wc_get_order_statuses() ) as $status ) {
+			unset( $wp_post_statuses[ $status ] );
+		}
+
+		try {
+			$queried_order_ids = wc_get_orders(
+				array(
+					'status' => 'foobar',
+					'limit'  => -1,
+					'return' => 'ids',
+				)
+			);
+		} finally {
+			// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the global changed above.
+			$wp_post_statuses = $registered_statuses;
+		}
+
+		$this->assertSame( array(), $queried_order_ids, 'The early-query fallback should only make order statuses known, not unknown ones.' );
+	}
+
+	/**
+	 * @testdox CPT order queries return every order when the "$filter" filter replaces the status with "any".
+	 *
+	 * @dataProvider cpt_query_args_filter_provider
+	 * @param string $filter Filter that receives the WP_Query args.
+	 */
+	public function test_cpt_order_queries_respect_filter_replacing_status_with_any( string $filter ): void {
+		$order_ids = $this->create_orders_with_interleaved_statuses( 3 );
+
+		add_filter(
+			$filter,
+			function ( $wp_query_args ) {
+				$wp_query_args['post_status'] = 'any';
+				return $wp_query_args;
+			}
+		);
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => 'foobar',
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( $order_ids, $queried_order_ids, 'A special status set by the filter should not be treated as unknown.' );
+	}
+
+	/**
+	 * @testdox CPT order queries do not fail when the woocommerce_order_data_store_cpt_get_orders_query filter returns a query string.
+	 */
+	public function test_cpt_order_queries_accept_query_string_from_filter(): void {
+		$order_ids = $this->create_orders_with_interleaved_statuses( 3 );
+
+		add_filter(
+			'woocommerce_order_data_store_cpt_get_orders_query',
+			function () {
+				return 'post_type=shop_order&post_status=' . OrderInternalStatus::COMPLETED . '&fields=ids';
+			}
+		);
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => OrderStatus::COMPLETED,
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array( $order_ids[2] ), $queried_order_ids, 'A query string returned by the filter should still reach WP_Query.' );
+	}
+
+	/**
+	 * @testdox CPT order queries return no orders for an order status that was left out of the post status registration.
+	 */
+	public function test_cpt_order_queries_return_empty_for_order_status_left_out_of_registration(): void {
+		global $wp_post_statuses;
+
+		$this->create_orders_with_interleaved_statuses( 3 );
+
+		$registered_statuses = $wp_post_statuses;
+		unset( $wp_post_statuses[ OrderInternalStatus::COMPLETED ] );
+
+		try {
+			$queried_order_ids = wc_get_orders(
+				array(
+					'status' => OrderStatus::COMPLETED,
+					'limit'  => -1,
+					'return' => 'ids',
+				)
+			);
+		} finally {
+			// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the global changed above.
+			$wp_post_statuses = $registered_statuses;
+		}
+
+		$this->assertSame( array(), $queried_order_ids, 'A status listed by wc_get_order_statuses() but not registered as a post status should not make the query return every order.' );
+	}
+
+	/**
+	 * Provides filters that can change the WP_Query args of CPT order queries.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public function cpt_query_args_filter_provider(): array {
+		return array(
+			'woocommerce_get_wp_query_args' => array( 'woocommerce_get_wp_query_args' ),
+			'woocommerce_order_data_store_cpt_get_orders_query' => array( 'woocommerce_order_data_store_cpt_get_orders_query' ),
+		);
+	}
+
 	/**
 	 * @testdox Unsupported order query arguments are reported in English, with the singular or plural wording for their count, whatever the site language.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableQueryTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableQueryTests.php
index 6bcb766eb8c..752b49eacbb 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableQueryTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableQueryTests.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableQuery;
 use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
 use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use Automattic\WooCommerce\Tests\Helpers\InterleavedStatusOrdersTrait;
 use Automattic\WooCommerce\Utilities\OrderUtil;
 use WC_Helper_Product;
 use WC_Order;
@@ -21,6 +22,7 @@ use WC_Order;
  */
 class OrdersTableQueryTests extends \WC_Unit_Test_Case {
 	use HPOSToggleTrait;
+	use InterleavedStatusOrdersTrait;

 	/**
 	 * Whether this class created the address full-text index.
@@ -1103,24 +1105,340 @@ class OrdersTableQueryTests extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * Helper function to create orders with interleaved statuses and strictly decreasing creation dates.
+	 * @testdox wc_get_orders returns no orders when filtering by an unregistered custom status.
 	 *
-	 * @param int $count Number of orders to create.
-	 * @return int[] Order IDs, ordered by creation date descending.
+	 * @dataProvider order_storage_provider
+	 * @param bool $hpos_enabled Whether HPOS is enabled.
 	 */
-	private function create_orders_with_interleaved_statuses( int $count ): array {
-		$statuses = array( OrderStatus::PENDING, OrderStatus::PROCESSING, OrderStatus::COMPLETED );
-		$ids      = array();
-
-		for ( $i = 0; $i < $count; $i++ ) {
-			$order = new \WC_Order();
-			$order->set_status( $statuses[ $i % count( $statuses ) ] );
-			$order->set_date_created( strtotime( '2023-06-01 12:00:00' ) - ( $i * HOUR_IN_SECONDS ) );
-			$order->save();
-			$ids[] = $order->get_id();
-		}
+	public function test_wc_get_orders_returns_empty_for_unregistered_custom_status( bool $hpos_enabled ): void {
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+		$this->create_order_with_unregistered_custom_status();
+		$this->create_orders_with_interleaved_statuses( 3 );
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => 'foobar',
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array(), $queried_order_ids, 'An unregistered custom status should not match orphaned or core-status orders' );
+	}
+
+	/**
+	 * @testdox wc_get_orders retains known statuses when filtering by known and unknown statuses.
+	 *
+	 * @dataProvider order_storage_provider
+	 * @param bool $hpos_enabled Whether HPOS is enabled.
+	 */
+	public function test_wc_get_orders_filters_by_known_status_when_mixed_with_unknown_status( bool $hpos_enabled ): void {
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+		$this->create_order_with_unregistered_custom_status();
+		$core_order_ids = $this->create_orders_with_interleaved_statuses( 3 );
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => array( OrderStatus::PROCESSING, 'foobar' ),
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array( $core_order_ids[1] ), $queried_order_ids, 'Known statuses should still be applied when unknown statuses are also requested' );
+	}
+
+	/**
+	 * @testdox wc_get_orders supports the "$status" special status.
+	 *
+	 * @dataProvider special_status_provider
+	 * @param bool         $hpos_enabled Whether HPOS is enabled.
+	 * @param string|array $status       Special status value.
+	 */
+	public function test_wc_get_orders_supports_special_status_values( bool $hpos_enabled, $status ): void {
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+		$order_ids = $this->create_orders_with_interleaved_statuses( 3 );
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'limit'  => -1,
+				'status' => $status,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( $order_ids, $queried_order_ids, 'A special status should not filter out orders with registered statuses.' );
+	}
+
+	/**
+	 * Provides special status values supported by order queries, for both order storages.
+	 *
+	 * @return array<string, array{bool, string|array}>
+	 */
+	public function special_status_provider(): array {
+		return array(
+			'HPOS, any'                       => array( true, 'any' ),
+			'HPOS, all'                       => array( true, 'all' ),
+			'HPOS, uppercase any'             => array( true, 'ANY' ),
+			'HPOS, padded all'                => array( true, ' all ' ),
+			'HPOS, all with unknown'          => array( true, array( 'all', 'foobar' ) ),
+			'HPOS, any with unknown'          => array( true, array( 'any', 'foobar' ) ),
+			'HPOS, all with known'            => array( true, array( 'all', OrderStatus::PROCESSING ) ),
+			'HPOS, all in comma string'       => array( true, 'all,' . OrderStatus::PROCESSING ),
+			'legacy CPT, any'                 => array( false, 'any' ),
+			'legacy CPT, all'                 => array( false, 'all' ),
+			'legacy CPT, uppercase any'       => array( false, 'ANY' ),
+			'legacy CPT, padded all'          => array( false, ' all ' ),
+			'legacy CPT, all with unknown'    => array( false, array( 'all', 'foobar' ) ),
+			'legacy CPT, any with unknown'    => array( false, array( 'any', 'foobar' ) ),
+			'legacy CPT, all with known'      => array( false, array( 'all', OrderStatus::PROCESSING ) ),
+			'legacy CPT, all in comma string' => array( false, 'all,' . OrderStatus::PROCESSING ),
+		);
+	}
+
+	/**
+	 * @testdox wc_get_orders returns only matching orders when the known status "$status" is supplied in uppercase.
+	 *
+	 * @testWith [true, "WC-COMPLETED"]
+	 *           [true, "COMPLETED"]
+	 *           [false, "WC-COMPLETED"]
+	 *           [false, "COMPLETED"]
+	 *
+	 * @param bool   $hpos_enabled Whether HPOS is enabled.
+	 * @param string $status       Uppercase status value.
+	 */
+	public function test_wc_get_orders_filters_by_uppercase_known_status( bool $hpos_enabled, string $status ): void {
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+		$order_ids = $this->create_orders_with_interleaved_statuses( 3 );
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => $status,
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array( $order_ids[2] ), $queried_order_ids, 'An uppercase known status should match its registered lowercase counterpart and return only the matching orders.' );
+	}
+
+	/**
+	 * @testdox wc_get_orders matches a known status surrounded by whitespace.
+	 *
+	 * @dataProvider order_storage_provider
+	 * @param bool $hpos_enabled Whether HPOS is enabled.
+	 */
+	public function test_wc_get_orders_trims_status_values( bool $hpos_enabled ): void {
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+		$order_ids = $this->create_orders_with_interleaved_statuses( 3 );
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => array( ' ' . OrderStatus::COMPLETED . ' ' ),
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array( $order_ids[2] ), $queried_order_ids, 'Whitespace around a known status should be ignored on both order storages.' );
+	}
+
+	/**
+	 * @testdox wc_get_orders returns no orders for an unknown status that already has the "wc-" prefix.
+	 *
+	 * @dataProvider order_storage_provider
+	 * @param bool $hpos_enabled Whether HPOS is enabled.
+	 */
+	public function test_wc_get_orders_returns_empty_for_prefixed_unknown_status( bool $hpos_enabled ): void {
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+		$this->create_orders_with_interleaved_statuses( 3 );
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => 'wc-foobar',
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array(), $queried_order_ids, 'A prefixed unknown status should not make the query return every order.' );
+	}
+
+	/**
+	 * @testdox wc_get_orders matches orders stored with an unregistered "wc-" prefixed status on HPOS only.
+	 *
+	 * @dataProvider order_storage_provider
+	 * @param bool $hpos_enabled Whether HPOS is enabled.
+	 */
+	public function test_wc_get_orders_prefixed_unregistered_custom_status_matches_orphaned_orders_only_on_hpos( bool $hpos_enabled ): void {
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+		$orphaned_order_id = $this->create_order_with_unregistered_custom_status();
+		$this->create_orders_with_interleaved_statuses( 3 );
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => 'wc-foobar',
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$expected = $hpos_enabled ? array( $orphaned_order_id ) : array();
+		$this->assertSame( $expected, $queried_order_ids, 'HPOS matches the stored status literally, while CPT treats the unregistered status as unknown.' );
+	}
+
+	/**
+	 * @testdox wc_get_orders splits a comma-separated status string and ignores whitespace around each status.
+	 *
+	 * @dataProvider order_storage_provider
+	 * @param bool $hpos_enabled Whether HPOS is enabled.
+	 */
+	public function test_wc_get_orders_splits_comma_separated_statuses( bool $hpos_enabled ): void {
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+		$order_ids = $this->create_orders_with_interleaved_statuses( 3 );
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => 'foobar, ' . OrderStatus::PROCESSING . ',' . OrderStatus::COMPLETED,
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array( $order_ids[1], $order_ids[2] ), $queried_order_ids, 'Each status in a comma-separated list should be matched on both order storages.' );
+	}
+
+	/**
+	 * @testdox wc_get_orders ignores non-string values in the status array instead of throwing a TypeError.
+	 *
+	 * @dataProvider order_storage_provider
+	 * @param bool $hpos_enabled Whether HPOS is enabled.
+	 */
+	public function test_wc_get_orders_ignores_non_string_status_values( bool $hpos_enabled ): void {
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+		$order_ids = $this->create_orders_with_interleaved_statuses( 3 );
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => array( OrderStatus::COMPLETED, array( 'nested' ), new \stdClass() ),
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array( $order_ids[2] ), $queried_order_ids, 'Non-string status values should be ignored while valid statuses are still applied.' );
+	}
+
+	/**
+	 * @testdox wc_get_orders returns no orders when every status value is non-string.
+	 *
+	 * @dataProvider order_storage_provider
+	 * @param bool $hpos_enabled Whether HPOS is enabled.
+	 */
+	public function test_wc_get_orders_returns_empty_when_all_status_values_are_non_string( bool $hpos_enabled ): void {
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+		$this->create_orders_with_interleaved_statuses( 3 );
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => array( array( 'nested' ), new \stdClass() ),
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array(), $queried_order_ids, 'A status array with only non-string values should not be treated as "any" and return every order.' );
+	}
+
+	/**
+	 * @testdox wc_get_orders does not use the properties of an object passed as the status.
+	 *
+	 * @dataProvider order_storage_provider
+	 * @param bool $hpos_enabled Whether HPOS is enabled.
+	 */
+	public function test_wc_get_orders_ignores_object_status( bool $hpos_enabled ): void {
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+		$this->create_orders_with_interleaved_statuses( 3 );
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => (object) array( 'status' => OrderStatus::COMPLETED ),
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array(), $queried_order_ids, 'Only strings are accepted as statuses, so an object should not be read as a list of statuses.' );
+	}
+
+	/**
+	 * @testdox wc_get_orders returns no orders when every status value is an empty-like string.
+	 *
+	 * @testWith [true, ["0"]]
+	 *           [true, "0"]
+	 *           [true, [""]]
+	 *           [true, ["  "]]
+	 *           [false, ["0"]]
+	 *           [false, "0"]
+	 *           [false, [""]]
+	 *           [false, ["  "]]
+	 *
+	 * @param bool         $hpos_enabled Whether HPOS is enabled.
+	 * @param string|array $statuses     The status query var value.
+	 */
+	public function test_wc_get_orders_returns_empty_when_all_status_values_are_empty_like( bool $hpos_enabled, $statuses ): void {
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+		$this->create_orders_with_interleaved_statuses( 3 );
+
+		$queried_order_ids = wc_get_orders(
+			array(
+				'status' => $statuses,
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertSame( array(), $queried_order_ids, 'A status with only empty-like strings should not drop the status condition and return every order.' );
+	}
+
+	/**
+	 * Provides order storage configurations.
+	 *
+	 * @return array<string, array{bool}>
+	 */
+	public function order_storage_provider(): array {
+		return array(
+			'HPOS'       => array( true ),
+			'legacy CPT' => array( false ),
+		);
+	}
+
+	/**
+	 * Creates an order with a custom status, then unregisters the status.
+	 *
+	 * @return int ID of the created order.
+	 */
+	private function create_order_with_unregistered_custom_status(): int {
+		global $wp_post_statuses;
+
+		$register_custom_status = function ( array $statuses ): array {
+			$statuses['wc-foobar'] = 'Foobar';
+			return $statuses;
+		};
+
+		register_post_status(
+			'wc-foobar',
+			array(
+				'label'               => 'Foobar',
+				'exclude_from_search' => false,
+			)
+		);
+		add_filter( 'wc_order_statuses', $register_custom_status );
+		$order = new WC_Order();
+		$order->set_status( 'foobar' );
+		$order->save();
+		remove_filter( 'wc_order_statuses', $register_custom_status );
+		unset( $wp_post_statuses['wc-foobar'] );

-		return $ids;
+		return $order->get_id();
 	}

 	/**