Commit 0586265ba62 for woocommerce

commit 0586265ba629d51bdab4e2649b65f22f8c7d1bf2
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date:   Tue Sep 29 14:40:02 2026 +0100

    Add opt-in exclusion for shoppers' stale stock holds (#68963)

    * Add opt-in exclusion for shoppers' stale stock holds

    * Add changelog for stale stock hold exclusion

diff --git a/plugins/woocommerce/changelog/fix-67354-own-reserved-stock-grace-window b/plugins/woocommerce/changelog/fix-67354-own-reserved-stock-grace-window
new file mode 100644
index 00000000000..bee4c4ceb10
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-67354-own-reserved-stock-grace-window
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add an opt-in filter (woocommerce_own_stock_hold_exclusion_threshold_minutes) for excluding a shopper's own stale stock holds.
diff --git a/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php b/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php
index 3ecf85a47bf..e507401040e 100644
--- a/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php
+++ b/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Checkout\Helpers;

 use Automattic\WooCommerce\Enums\OrderInternalStatus;
 use Automattic\WooCommerce\Enums\OrderItemType;
+use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Utilities\OrderUtil;
 use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;

@@ -17,6 +18,22 @@ defined( 'ABSPATH' ) || exit;
  */
 final class ReserveStock {

+	/**
+	 * Session key listing orders this shopper has taken a stock hold for.
+	 *
+	 * Holds array( 'customer' => string, 'order_ids' => int[] ), where `customer`
+	 * is the session customer id that recorded them.
+	 */
+	private const OWN_ORDERS_SESSION_KEY = 'stock_holding_orders';
+
+	/**
+	 * Upper bound on own orders considered when excluding a shopper's stale holds.
+	 *
+	 * Keeps the generated IN clause bounded. A shopper who exceeds it loses the
+	 * oldest ids from the list, and those holds block again until they expire.
+	 */
+	private const MAX_OWN_ORDERS = 10;
+
 	/**
 	 * Is stock reservation enabled?
 	 *
@@ -24,6 +41,16 @@ final class ReserveStock {
 	 */
 	private $enabled = true;

+	/**
+	 * Request scoped memo of a signed in customer's potential stock-holding order ids.
+	 *
+	 * A new ReserveStock is constructed for each wc_get_held_stock_quantity()
+	 * call, so the memo cannot live on the instance.
+	 *
+	 * @var array<int, int[]>
+	 */
+	private static $potential_order_ids_by_customer = array();
+
 	/**
 	 * Constructor
 	 */
@@ -149,6 +176,8 @@ final class ReserveStock {
 				foreach ( $rows as $product_id => $quantity ) {
 					$this->reserve_stock_for_product( $product_id, $quantity, $order, $minutes );
 				}
+
+				$this->remember_order_for_shopper( $order );
 			}
 		} catch ( ReserveStockException $e ) {
 			$this->release_stock_for_order( $order );
@@ -293,6 +322,8 @@ final class ReserveStock {
 		);
 		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

+		$query .= $this->get_stale_own_holds_exclusion_clause( $product_id, $exclude_order_id );
+
 		/**
 		 * Filter: woocommerce_query_for_reserved_stock
 		 * Allows to filter the query for getting reserved stock of a product.
@@ -304,4 +335,184 @@ final class ReserveStock {
 		 */
 		return apply_filters( 'woocommerce_query_for_reserved_stock', $query, $product_id, $exclude_order_id );
 	}
+
+	/**
+	 * Builds a SQL clause excluding the current shopper's stale stock holds.
+	 *
+	 * @param int $product_id       Product whose reserved stock is being counted.
+	 * @param int $exclude_order_id Order already excluded by the caller.
+	 * @return string SQL clause, or an empty string when no holds qualify.
+	 */
+	private function get_stale_own_holds_exclusion_clause( $product_id, $exclude_order_id ): string {
+		global $wpdb;
+
+		/**
+		 * Filters how many minutes must pass before a shopper's own unpaid stock hold
+		 * stops blocking that same shopper.
+		 *
+		 * The exclusion is disabled by default. Enabling it can create multiple payable
+		 * orders for the same stock and cause overselling if an older payment later
+		 * completes. Return 0 to exclude the shopper's own holds immediately.
+		 *
+		 * @since 11.3.0
+		 *
+		 * @param int|false $threshold_minutes Threshold in minutes, or false to keep the exclusion disabled.
+		 * @param int       $product_id        Product whose reserved stock is being counted.
+		 * @param int       $exclude_order_id  Order already excluded by the caller.
+		 */
+		$threshold_minutes = apply_filters( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', false, $product_id, $exclude_order_id );
+
+		if ( false === $threshold_minutes || ! is_numeric( $threshold_minutes ) ) {
+			return '';
+		}
+
+		$threshold_minutes = max( 0, (int) $threshold_minutes );
+
+		$order_ids = array_values( array_diff( $this->get_current_shopper_stock_holding_order_ids(), array( absint( $exclude_order_id ) ) ) );
+
+		if ( empty( $order_ids ) ) {
+			return '';
+		}
+
+		$placeholders = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) );
+
+		// The `timestamp` column records when the hold was first placed, so it
+		// measures the age of the shopper's attempt rather than of the last write.
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The interpolated value contains only generated %d placeholders.
+		return $wpdb->prepare(
+			"
+			AND NOT (
+				stock_table.`order_id` IN ( $placeholders )
+				AND stock_table.`timestamp` < ( NOW() - INTERVAL %d MINUTE )
+			)
+			",
+			array_merge( $order_ids, array( $threshold_minutes ) )
+		);
+		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+	}
+
+	/**
+	 * Returns stock-holding order ids associated with the current shopper.
+	 *
+	 * Includes orders recorded by the current session and unpaid orders belonging
+	 * to the signed-in customer. Ownership is never inferred from anything a
+	 * shopper can type, such as the billing email.
+	 *
+	 * @return int[]
+	 */
+	private function get_current_shopper_stock_holding_order_ids(): array {
+		$session = WC()->session;
+
+		// In the admin, WP-CLI and cron there is no shopper and WC()->session is
+		// not initialised, so nothing is ever treated as the requester's own hold.
+		if ( ! $session instanceof \WC_Session ) {
+			return array();
+		}
+
+		$order_ids = $this->get_session_stock_holding_order_ids( $session );
+
+		$customer_id = get_current_user_id();
+
+		if ( $customer_id ) {
+			$order_ids = array_merge( $order_ids, $this->get_potential_stock_holding_order_ids_for_customer( $customer_id ) );
+		}
+
+		return array_values( array_filter( array_unique( $order_ids ) ) );
+	}
+
+	/**
+	 * Returns the stock-holding order ids recorded against the current session.
+	 *
+	 * The list is only trusted if it was written under this session's customer id.
+	 *
+	 * @param \WC_Session $session Session of the shopper making this request.
+	 * @return int[]
+	 */
+	private function get_session_stock_holding_order_ids( \WC_Session $session ): array {
+		$remembered = $session->get( self::OWN_ORDERS_SESSION_KEY, array() );
+
+		if ( ! is_array( $remembered ) ) {
+			return array();
+		}
+
+		if ( ! isset( $remembered['customer'], $remembered['order_ids'] ) || ! is_array( $remembered['order_ids'] ) ) {
+			return array();
+		}
+
+		// Core hands session data to a different shopper in two places (cart token
+		// cloning and guest-to-user migration), and both change the session
+		// customer id first. A mismatch means the list arrived from somebody
+		// else's session, so it confers no ownership.
+		if ( (string) $remembered['customer'] !== (string) $session->get_customer_id() ) {
+			return array();
+		}
+
+		return array_slice( array_map( 'absint', $remembered['order_ids'] ), 0, self::MAX_OWN_ORDERS );
+	}
+
+	/**
+	 * Records a stock-holding order against the current customer session.
+	 *
+	 * The session's customer id is stamped alongside the ids so the list can be
+	 * discarded if it later turns up in somebody else's session. See
+	 * get_session_stock_holding_order_ids().
+	 *
+	 * @param \WC_Order $order Order that now holds stock.
+	 */
+	private function remember_order_for_shopper( \WC_Order $order ): void {
+		$session = WC()->session;
+
+		// An order created in the admin or over WP-CLI has no session to record against.
+		if ( ! $session instanceof \WC_Session ) {
+			return;
+		}
+
+		$order_ids = $this->get_session_stock_holding_order_ids( $session );
+
+		array_unshift( $order_ids, $order->get_id() );
+
+		$order_ids = array_slice( array_values( array_unique( array_map( 'absint', $order_ids ) ) ), 0, self::MAX_OWN_ORDERS );
+
+		$session->set(
+			self::OWN_ORDERS_SESSION_KEY,
+			array(
+				'customer'  => (string) $session->get_customer_id(),
+				'order_ids' => $order_ids,
+			)
+		);
+	}
+
+	/**
+	 * Returns order ids of a signed-in customer that could be holding stock, newest first.
+	 *
+	 * Covers a shopper whose session pointers were lost (new device, cleared
+	 * cookies, lapsed session). The statuses match the ones the reserved stock
+	 * query counts.
+	 *
+	 * @param int $customer_id Customer ID.
+	 * @return int[]
+	 */
+	private function get_potential_stock_holding_order_ids_for_customer( int $customer_id ): array {
+		if ( isset( self::$potential_order_ids_by_customer[ $customer_id ] ) ) {
+			return self::$potential_order_ids_by_customer[ $customer_id ];
+		}
+
+		/** @var int[] $order_ids */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
+		$order_ids = wc_get_orders(
+			array(
+				'customer' => $customer_id,
+				'status'   => array( OrderStatus::CHECKOUT_DRAFT, OrderStatus::PENDING ),
+				'limit'    => self::MAX_OWN_ORDERS,
+				'orderby'  => 'date',
+				'order'    => 'DESC',
+				'return'   => 'ids',
+			)
+		);
+
+		$order_ids = array_map( 'absint', $order_ids );
+
+		self::$potential_order_ids_by_customer[ $customer_id ] = $order_ids;
+
+		return $order_ids;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Checkout/Helpers/ReserveStockOwnHoldsTest.php b/plugins/woocommerce/tests/php/src/Checkout/Helpers/ReserveStockOwnHoldsTest.php
new file mode 100644
index 00000000000..c03fc4844eb
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Checkout/Helpers/ReserveStockOwnHoldsTest.php
@@ -0,0 +1,499 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Checkout\Helpers;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use WC_Customer;
+use WC_Helper_Order;
+use WC_Helper_Product;
+use WC_Product_Simple;
+use WC_Session_Handler;
+use WC_Unit_Test_Case;
+
+/**
+ * Class ReserveStockOwnHoldsTest.
+ *
+ * Exercises the opt-in threshold that stops a shopper being blocked by their
+ * own stale, unpaid stock hold:
+ *
+ *   1. A hold belonging to another shopper always blocks, however old it is.
+ *   2. The shopper's own hold blocks inside the threshold and is excluded past it.
+ *   3. Ownership never comes from anything the shopper can type.
+ *   4. With no session (admin, WP-CLI, cron) nothing is excluded.
+ *   5. The woocommerce_query_for_reserved_stock signature is unchanged.
+ *
+ * Holds are written straight into wc_reserved_stock so `timestamp` can be aged
+ * exactly. Tests exercising the session list call wc_reserve_stock_for_order()
+ * for real.
+ */
+class ReserveStockOwnHoldsTest extends WC_Unit_Test_Case {
+
+	/**
+	 * How long the reservation itself lasts, well beyond any threshold used here.
+	 */
+	private const HOLD_MINUTES = 2880;
+
+	/**
+	 * The session the bootstrap set up, put back in tearDown.
+	 *
+	 * @var \WC_Session|null
+	 */
+	private $original_session;
+
+	/**
+	 * The customer the bootstrap set up, put back in tearDown.
+	 *
+	 * @var WC_Customer|null
+	 */
+	private $original_customer;
+
+	/**
+	 * $_COOKIE as it was before the test, put back in tearDown.
+	 *
+	 * @var array
+	 */
+	private $original_cookies;
+
+	/**
+	 * Set up a shopper session and a store that manages stock.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		update_option( 'woocommerce_manage_stock', 'yes' );
+		update_option( 'woocommerce_schema_version', 430 );
+		update_option( 'woocommerce_hold_stock_minutes', self::HOLD_MINUTES );
+
+		$this->original_session  = WC()->session;
+		$this->original_customer = WC()->customer;
+
+		// Earlier suites can leave a valid session cookie in $_COOKIE (the test
+		// framework never resets it), and WC_Session_Handler::init() would then
+		// give every session in these tests that cookie's customer id instead of
+		// minting distinct ones.
+		$this->original_cookies = $_COOKIE;
+
+		/** This filter is documented in includes/class-wc-session-handler.php */
+		unset( $_COOKIE[ (string) apply_filters( 'woocommerce_cookie', 'wp_woocommerce_session_' . COOKIEHASH ) ] );
+
+		WC()->session = new WC_Session_Handler();
+		WC()->session->init();
+	}
+
+	/**
+	 * Leave no session, user or reservation behind.
+	 *
+	 * The session and customer singletons are restored rather than nulled: this
+	 * suite runs in one process, and a null WC()->session fatals later classes.
+	 */
+	public function tearDown(): void {
+		global $wpdb;
+
+		$wpdb->query( "DELETE FROM {$wpdb->wc_reserved_stock}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+		wp_set_current_user( 0 );
+
+		$_COOKIE       = $this->original_cookies;
+		WC()->session  = $this->original_session;
+		WC()->customer = $this->original_customer;
+
+		parent::tearDown();
+	}
+
+	// ---------------------------------------------------------------------
+	// 1. Cross-shopper isolation.
+	// ---------------------------------------------------------------------
+
+	/**
+	 * @testdox A stale hold belonging to another shopper remains counted when the exclusion is enabled.
+	 */
+	public function test_own_stale_hold_does_not_exclude_another_shoppers_hold(): void {
+		$product = $this->create_stock_managed_product( 2 );
+		$shopper = $this->create_signed_in_shopper();
+		$mine    = $this->create_unpaid_order_for( $shopper, $product );
+		$theirs  = $this->create_shopper_with_unpaid_order( $product );
+
+		$this->hold_stock( $mine, $product->get_id(), 1, 120 );
+		$this->hold_stock( $theirs, $product->get_id(), 1, 120 );
+
+		$this->assertSame( 1, $this->get_held_stock_quantity_with_threshold( $product, 0, 10 ) );
+	}
+
+	// ---------------------------------------------------------------------
+	// 2. The exclusion threshold.
+	// ---------------------------------------------------------------------
+
+	/**
+	 * @testdox The shopper's own hold remains counted inside an opted-in threshold.
+	 */
+	public function test_own_fresh_hold_still_blocks(): void {
+		$product = $this->create_stock_managed_product( 1 );
+		$shopper = $this->create_signed_in_shopper();
+		$order   = $this->create_unpaid_order_for( $shopper, $product );
+
+		$this->hold_stock( $order, $product->get_id(), 1, 9 );
+
+		$this->assertSame( 1, $this->get_held_stock_quantity_with_threshold( $product, 0, 10 ) );
+	}
+
+	/**
+	 * @testdox The shopper's own hold is excluded after an opted-in threshold.
+	 */
+	public function test_own_stale_hold_is_excluded(): void {
+		$product = $this->create_stock_managed_product( 1 );
+		$shopper = $this->create_signed_in_shopper();
+		$order   = $this->create_unpaid_order_for( $shopper, $product );
+
+		$this->hold_stock( $order, $product->get_id(), 1, 11 );
+
+		$this->assertSame( 0, $this->get_held_stock_quantity_with_threshold( $product, 0, 10 ) );
+	}
+
+	/**
+	 * @testdox The shopper's own stale hold remains counted by default.
+	 */
+	public function test_own_stale_hold_blocks_by_default(): void {
+		$product = $this->create_stock_managed_product( 1 );
+		$shopper = $this->create_signed_in_shopper();
+		$order   = $this->create_unpaid_order_for( $shopper, $product );
+
+		$this->hold_stock( $order, $product->get_id(), 1, 2000 );
+
+		$this->assertSame( 1, wc_get_held_stock_quantity( $product, 0 ) );
+	}
+
+	/**
+	 * @testdox An invalid threshold leaves the shopper's own hold counted.
+	 */
+	public function test_invalid_threshold_keeps_own_hold_blocking(): void {
+		$product = $this->create_stock_managed_product( 1 );
+		$shopper = $this->create_signed_in_shopper();
+		$order   = $this->create_unpaid_order_for( $shopper, $product );
+
+		$this->hold_stock( $order, $product->get_id(), 1, 2000 );
+
+		$return_invalid_threshold = static function () {
+			return array();
+		};
+
+		add_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', $return_invalid_threshold );
+		try {
+			$held = wc_get_held_stock_quantity( $product, 0 );
+		} finally {
+			remove_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', $return_invalid_threshold );
+		}
+
+		$this->assertSame( 1, $held );
+	}
+
+	/**
+	 * @testdox A zero threshold excludes the shopper's own hold immediately.
+	 */
+	public function test_zero_threshold_excludes_own_hold_immediately(): void {
+		$product = $this->create_stock_managed_product( 1 );
+		$shopper = $this->create_signed_in_shopper();
+		$order   = $this->create_unpaid_order_for( $shopper, $product );
+
+		$this->hold_stock( $order, $product->get_id(), 1, 5 );
+
+		$this->assertSame( 0, $this->get_held_stock_quantity_with_threshold( $product, 0, 0 ) );
+	}
+
+	// ---------------------------------------------------------------------
+	// 3. Ownership sources.
+	// ---------------------------------------------------------------------
+
+	/**
+	 * @testdox The session list identifies a guest's hold after the draft pointer moves to a new order.
+	 */
+	public function test_session_list_covers_a_guest_whose_draft_pointer_moved_on(): void {
+		$product = $this->create_stock_managed_product( 1 );
+		$order   = $this->create_unpaid_order_for( 0, $product );
+
+		// The real reservation path, which is what records the order in the session.
+		wc_reserve_stock_for_order( wc_get_order( $order ) );
+		$this->age_hold( $order, 11 );
+
+		// The Store API has since minted a new draft and repointed the session at it.
+		$new_draft = $this->create_unpaid_order_for( 0, $product, OrderStatus::CHECKOUT_DRAFT );
+		WC()->session->set( 'store_api_draft_order', $new_draft );
+
+		$this->assertSame( 0, $this->get_held_stock_quantity_with_threshold( $product, $new_draft, 10 ) );
+	}
+
+	/**
+	 * @testdox A session list copied from another shopper does not confer ownership.
+	 */
+	public function test_borrowed_session_list_confers_no_ownership(): void {
+		$product = $this->create_stock_managed_product( 1 );
+		$order   = $this->create_unpaid_order_for( 0, $product );
+
+		wc_reserve_stock_for_order( wc_get_order( $order ) );
+		$this->age_hold( $order, 11 );
+
+		$borrowed = WC()->session->get( 'stock_holding_orders', array() );
+
+		// A second shopper, whose session carries the first shopper's list verbatim
+		// but under their own, different customer id.
+		WC()->session = new WC_Session_Handler();
+		WC()->session->init();
+		WC()->session->set( 'stock_holding_orders', $borrowed );
+
+		$this->assertNotSame(
+			$borrowed['customer'],
+			(string) WC()->session->get_customer_id(),
+			'Precondition: the borrowing session has a different customer id.'
+		);
+		$this->assertSame( 1, $this->get_held_stock_quantity_with_threshold( $product, 0, 10 ) );
+	}
+
+	/**
+	 * @testdox A matching billing email does not confer ownership of another shopper's hold.
+	 */
+	public function test_billing_email_confers_no_ownership(): void {
+		$product      = $this->create_stock_managed_product( 1 );
+		$victim       = $this->create_signed_in_shopper( 'victim@example.com' );
+		$order        = $this->create_unpaid_order_for( $victim, $product );
+		$victim_order = wc_get_order( $order );
+		$victim_order->set_billing_email( 'victim@example.com' );
+		$victim_order->save();
+
+		$this->hold_stock( $order, $product->get_id(), 1, 120 );
+
+		// Now a different, unrelated shopper claiming the same address.
+		wp_set_current_user( 0 );
+		WC()->session = new WC_Session_Handler();
+		WC()->session->init();
+		WC()->customer = new WC_Customer();
+		WC()->customer->set_billing_email( 'victim@example.com' );
+
+		$this->assertSame( 1, $this->get_held_stock_quantity_with_threshold( $product, 0, 10 ) );
+	}
+
+	/**
+	 * @testdox A guest's holds beyond the session list limit remain counted.
+	 */
+	public function test_own_holds_beyond_the_session_list_limit_block_again(): void {
+		$product = $this->create_stock_managed_product( 20 );
+
+		// One more attempt than the list keeps.
+		for ( $i = 0; $i < 11; $i++ ) {
+			$order = $this->create_unpaid_order_for( 0, $product );
+			wc_reserve_stock_for_order( wc_get_order( $order ) );
+			$this->age_hold( $order, 11 );
+		}
+
+		// The list keeps the ten newest ids, so the first order's hold counts
+		// again while the remembered ten are excluded.
+		$this->assertSame( 1, $this->get_held_stock_quantity_with_threshold( $product, 0, 10 ) );
+	}
+
+	// ---------------------------------------------------------------------
+	// 4. Contexts with no shopper.
+	// ---------------------------------------------------------------------
+
+	/**
+	 * @testdox No holds are excluded when there is no shopper session.
+	 */
+	public function test_no_session_leaves_the_total_unchanged(): void {
+		$product = $this->create_stock_managed_product( 1 );
+		$shopper = $this->create_signed_in_shopper();
+		$order   = $this->create_unpaid_order_for( $shopper, $product );
+
+		$this->hold_stock( $order, $product->get_id(), 1, 120 );
+
+		WC()->session = null;
+
+		$this->assertSame( 1, $this->get_held_stock_quantity_with_threshold( $product, 0, 10 ) );
+	}
+
+	// ---------------------------------------------------------------------
+	// 5. The public filter contract.
+	// ---------------------------------------------------------------------
+
+	/**
+	 * @testdox The existing reserved-stock query filter receives its unchanged arguments and the exclusion clause.
+	 */
+	public function test_filter_signature_is_unchanged(): void {
+		$product = $this->create_stock_managed_product( 1 );
+		$shopper = $this->create_signed_in_shopper();
+		$order   = $this->create_unpaid_order_for( $shopper, $product );
+
+		$this->hold_stock( $order, $product->get_id(), 1, 120 );
+
+		$seen = array();
+
+		$capture = function ( $query, $product_id, $exclude_order_id ) use ( &$seen ) {
+			$seen = array(
+				'query'            => $query,
+				'product_id'       => $product_id,
+				'exclude_order_id' => $exclude_order_id,
+			);
+
+			return $query;
+		};
+
+		add_filter( 'woocommerce_query_for_reserved_stock', $capture, 10, 3 );
+		try {
+			$this->get_held_stock_quantity_with_threshold( $product, 4242, 10 );
+		} finally {
+			remove_filter( 'woocommerce_query_for_reserved_stock', $capture, 10 );
+		}
+
+		$this->assertIsInt( $seen['exclude_order_id'] );
+		$this->assertSame( 4242, $seen['exclude_order_id'] );
+		$this->assertSame( $product->get_id(), $seen['product_id'] );
+		$this->assertStringContainsString( 'AND NOT (', $seen['query'], 'The filter sees the complete core query.' );
+	}
+
+	// ---------------------------------------------------------------------
+	// Helpers.
+	// ---------------------------------------------------------------------
+
+	/**
+	 * Gets held stock with the own-hold exclusion explicitly enabled.
+	 *
+	 * @param WC_Product_Simple $product          Product whose held stock is requested.
+	 * @param int               $exclude_order_id Order already excluded by the caller.
+	 * @param int               $threshold        Own-hold exclusion threshold in minutes.
+	 * @return int
+	 */
+	private function get_held_stock_quantity_with_threshold( WC_Product_Simple $product, int $exclude_order_id, int $threshold ): int {
+		$set_threshold = static function () use ( $threshold ) {
+			return $threshold;
+		};
+
+		add_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', $set_threshold );
+		try {
+			return wc_get_held_stock_quantity( $product, $exclude_order_id );
+		} finally {
+			remove_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', $set_threshold );
+		}
+	}
+
+	/**
+	 * A simple product managing a fixed quantity of stock.
+	 *
+	 * @param int $quantity Stock quantity.
+	 * @return WC_Product_Simple
+	 */
+	private function create_stock_managed_product( int $quantity ): WC_Product_Simple {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_manage_stock( true );
+		$product->set_stock_quantity( $quantity );
+		$product->save();
+
+		return $product;
+	}
+
+	/**
+	 * A signed in shopper. A fresh user per test, because the customer lookup is
+	 * memoised for the life of the request.
+	 *
+	 * @param string $email Optional email.
+	 * @return int User ID.
+	 */
+	private function create_signed_in_shopper( string $email = '' ): int {
+		$user_id = wp_insert_user(
+			array(
+				'user_login' => uniqid( 'shopper_' ),
+				'user_pass'  => wp_generate_password(),
+				'user_email' => '' !== $email ? $email : uniqid( 'shopper_' ) . '@example.com',
+				'role'       => 'customer',
+			)
+		);
+
+		wp_set_current_user( $user_id );
+
+		return (int) $user_id;
+	}
+
+	/**
+	 * An unpaid order for a customer, holding one unit of the product.
+	 *
+	 * WC_Helper_Order::create_order() hard-codes a line quantity of 4, so the
+	 * line item is rebuilt — with a save() between remove and add, without which
+	 * the order reloads with no items and reserves nothing.
+	 *
+	 * @param int               $customer_id Customer ID, 0 for a guest.
+	 * @param WC_Product_Simple $product     Product to add.
+	 * @param string            $status      Order status, unpaid pending by default.
+	 * @return int Order ID.
+	 */
+	private function create_unpaid_order_for( int $customer_id, WC_Product_Simple $product, string $status = OrderStatus::PENDING ): int {
+		$order = WC_Helper_Order::create_order( $customer_id );
+		$order->remove_order_items();
+		$order->save();
+		$order->add_product( wc_get_product( $product->get_id() ), 1 );
+		$order->set_status( $status );
+		$order->save();
+
+		return $order->get_id();
+	}
+
+	/**
+	 * An unpaid order belonging to somebody who is not the current user, and whose
+	 * id never enters the current session.
+	 *
+	 * @param WC_Product_Simple $product Product to add.
+	 * @return int Order ID.
+	 */
+	private function create_shopper_with_unpaid_order( WC_Product_Simple $product ): int {
+		$current = get_current_user_id();
+		$other   = $this->create_signed_in_shopper();
+		$order   = $this->create_unpaid_order_for( $other, $product );
+
+		wp_set_current_user( $current );
+
+		return $order;
+	}
+
+	/**
+	 * Write a hold directly, with an exact age.
+	 *
+	 * @param int $order_id    Order holding the stock.
+	 * @param int $product_id  Product held.
+	 * @param int $quantity    Quantity held.
+	 * @param int $age_minutes How long ago the hold was placed.
+	 */
+	private function hold_stock( int $order_id, int $product_id, int $quantity, int $age_minutes ): void {
+		global $wpdb;
+
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$wpdb->query(
+			$wpdb->prepare(
+				"
+				INSERT INTO {$wpdb->wc_reserved_stock} ( `order_id`, `product_id`, `stock_quantity`, `timestamp`, `expires` )
+				VALUES ( %d, %d, %d, ( NOW() - INTERVAL %d MINUTE ), ( NOW() + INTERVAL %d MINUTE ) )
+				",
+				$order_id,
+				$product_id,
+				$quantity,
+				$age_minutes,
+				self::HOLD_MINUTES
+			)
+		);
+		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+	}
+
+	/**
+	 * Backdate a hold that was created by the real reservation path.
+	 *
+	 * @param int $order_id    Order holding the stock.
+	 * @param int $age_minutes How long ago the hold should read as placed.
+	 */
+	private function age_hold( int $order_id, int $age_minutes ): void {
+		global $wpdb;
+
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$wpdb->query(
+			$wpdb->prepare(
+				"UPDATE {$wpdb->wc_reserved_stock} SET `timestamp` = ( NOW() - INTERVAL %d MINUTE ) WHERE `order_id` = %d",
+				$age_minutes,
+				$order_id
+			)
+		);
+		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+	}
+}