Commit e817ab399ec for woocommerce
commit e817ab399ecacee9ab1b064c8fe17ce8e560f47e
Author: oxfordmetadata <167354013+oxfordmetadata@users.noreply.github.com>
Date: Fri Aug 21 19:18:43 2026 +0300
Stop a shopper being blocked by their own stale stock reservation (#67751)
* Discount a shopper's own stale stock hold
check_cart_item_stock() and reserve_stock_for_product() exclude a single order id
from the reserved stock total, so a shopper with more than one unpaid order
holding the same product is blocked by their own earlier attempt for the whole of
woocommerce_hold_stock_minutes.
Discount a hold only when it belongs to the shopper making the request, its order
is unpaid, and it is older than a grace window, filterable via
woocommerce_own_reserved_stock_grace_minutes and defaulting to 10 minutes. Inside
the window an in-flight payment still blocks. Ownership comes from the session
pointers, a session list written as holds are taken, and the account when the
shopper is signed in, never from anything a shopper can type.
The lookup returns early unless WC()->session is a WC_Session, so the admin,
WP-CLI and cron get the query unchanged. $exclude_order_id stays scalar and the
clause is appended before woocommerce_query_for_reserved_stock fires, leaving the
filter signature documented since 4.5.0 untouched.
Part of #67354.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fix review findings: session ownership binding and test isolation
Ownership no longer trusts raw session contents. WC_Session_Handler hands one
shopper's session data to another in two places: clone_session_data() copies
everything but `customer` into a freshly minted session when a cart token is
presented, and migrate_guest_session_to_user_session() moves a guest session
wholesale onto whichever account next signs in on that browser. Because the
discount now reaches the reservation gate rather than only the display checks,
borrowed session data could have released a hold that belongs to somebody else.
The recorded list is therefore stamped with the session customer id that wrote
it and ignored when that no longer matches. Both of the paths above change the
customer id before the data moves, so both are detected. order_awaiting_payment
and store_api_draft_order are no longer read at all: they carry no record of who
wrote them, so they cannot be checked, and they add nothing, because an order can
only hold stock by passing through reserve_stock_for_order(), which is where the
bound list is written.
Test fixes:
- WC_Helper_Order::create_order() hard-codes a line-item quantity of 4, so
reserving for real against a 1-unit product threw ReserveStockException and
errored the test. The line item is now rebuilt at quantity 1, as
class-wc-cart-test.php does.
- tearDown() nulled WC()->session and replaced WC()->customer without restoring
either, which would have poisoned every later test class in the process. Both
singletons are now saved in setUp and put back.
- Added a case pinning that a borrowed session list confers no ownership.
remember_order_for_shopper() gains a void return type for PHPStan level 8.
Part of #67354.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fix order construction in ReserveStockOwnHoldsTest
remove_order_items() needs an intervening save() before add_product(), or the
replacement line item never reaches wc_order_items: the order reloads with no
items, reserve_stock_for_order() finds nothing to reserve, and the session list
is never written.
Verified by running the suite on both legs, 10 tests 16 assertions green on each:
vendor/bin/phpunit tests/php/src/Checkout/Helpers/ReserveStockOwnHoldsTest.php
DISABLE_HPOS=1 vendor/bin/phpunit tests/php/src/Checkout/Helpers/ReserveStockOwnHoldsTest.php
Part of #67354.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fix PHPStan level 8 error in get_unpaid_order_ids_for_customer()
array_map( 'absint', $order_ids ) failed argument.type: wc_get_orders() is
documented as returning WC_Order objects, so PHPStan rejected a callback typed
for ints. The 'ids' return actually yields plain ints, so both shapes are now
accepted explicitly and the variable is annotated to say so.
Verified locally:
vendor/bin/phpstan analyse -c phpstan.neon src/Checkout/Helpers/ReserveStock.php
-> No errors
phpcs src/Checkout/Helpers/ReserveStock.php
-> the same 4 errors unmodified trunk already reports, none new
vendor/bin/phpunit tests/php/src/Checkout/Helpers/ReserveStockOwnHoldsTest.php
-> OK (10 tests, 16 assertions), on both the default HPOS leg and DISABLE_HPOS=1
Part of #67354.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Isolate the test suite from session cookies leaked by earlier tests
test_borrowed_session_list_confers_no_ownership asserts that two freshly
initialised sessions get distinct customer ids. Standalone they do, but in
the full suite an earlier test can leave a valid session cookie in $_COOKIE
(the test framework resets $_GET/$_POST/$_REQUEST between tests, never
$_COOKIE), and WC_Session_Handler::init() then adopts that cookie's customer
id for every session the test creates, so the two ids compare identical.
Snapshot $_COOKIE and remove the WooCommerce session cookie in setUp, and
restore the snapshot in tearDown, so each handler mints its own id whatever
ran before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove temporary test filters in finally blocks and describe the test class
If wc_get_held_stock_quantity() throws, a remove_filter() placed after it
never runs and the filter leaks into later tests in the same process. Both
temporary filters are now removed in a finally block. Also gives the class
docblock a description.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address review: naming, docblock trims, filter semantics, test audit
- Rename methods and the filter as suggested; "discount" wording removed
to avoid confusion with pricing.
- woocommerce_own_stock_hold_exclusion_threshold_minutes: 0 now excludes
own holds immediately, false disables the exclusion; the filter also
receives $product_id and $exclude_order_id.
- Document and test the MAX_OWN_ORDERS overflow: the oldest ids drop off
the session list and those holds block again. The cap now also applies
when reading the list.
- Trim docblocks to the method contract; move context notes inline.
- Tests: drop the redundant another-shoppers-stale-hold case, split the
zero-window test into false/zero, and pass the new draft order id in
the moved-pointer case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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..99a5e645688
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-67354-own-reserved-stock-grace-window
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Stop a shopper being blocked by their own abandoned stock reservation. A hold placed by the same shopper is excluded once it is older than a threshold, filterable via woocommerce_own_stock_hold_exclusion_threshold_minutes and defaulting to 10 minutes. Holds belonging to other shoppers are unaffected.
diff --git a/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php b/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php
index 3ecf85a47bf..01fa2d893ef 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,23 @@ 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 simply block again until they
+ * expire — the failure direction is blocking, never overselling.
+ */
+ private const MAX_OWN_ORDERS = 10;
+
/**
* Is stock reservation enabled?
*
@@ -24,6 +42,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 +177,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 +323,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 +336,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.
+ *
+ * Only the shopper who placed the hold is affected. Return 0 to exclude own
+ * holds immediately, or false to disable the exclusion and keep own holds
+ * blocking for the full reservation window.
+ *
+ * @since 11.2.0
+ *
+ * @param int|false $threshold_minutes Threshold in minutes, or false to disable.
+ * @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', 10, $product_id, $exclude_order_id );
+
+ if ( false === $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..958c259f95b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Checkout/Helpers/ReserveStockOwnHoldsTest.php
@@ -0,0 +1,485 @@
+<?php
+/**
+ * Tests for excluding a shopper's own stale stock holds.
+ */
+
+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 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. The oversell invariant.
+ // ---------------------------------------------------------------------
+
+ /**
+ * A stale hold on an order the shopper does not own keeps blocking, even
+ * while the shopper's own stale hold is excluded.
+ */
+ public function test_own_stale_hold_does_not_exclude_another_shoppers_hold() {
+ $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, wc_get_held_stock_quantity( $product, 0 ) );
+ }
+
+ // ---------------------------------------------------------------------
+ // 2. The exclusion threshold.
+ // ---------------------------------------------------------------------
+
+ /**
+ * The shopper's own hold still blocks inside the default 10 minute threshold.
+ */
+ public function test_own_fresh_hold_still_blocks() {
+ $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, wc_get_held_stock_quantity( $product, 0 ) );
+ }
+
+ /**
+ * Past the threshold the shopper stops being blocked by their own attempt.
+ */
+ public function test_own_stale_hold_is_excluded() {
+ $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, wc_get_held_stock_quantity( $product, 0 ) );
+ }
+
+ /**
+ * A false threshold disables the exclusion and restores the behaviour from
+ * before this change.
+ */
+ public function test_false_threshold_keeps_own_hold_blocking() {
+ $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 );
+
+ add_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', '__return_false' );
+ try {
+ $held = wc_get_held_stock_quantity( $product, 0 );
+ } finally {
+ remove_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', '__return_false' );
+ }
+
+ $this->assertSame( 1, $held );
+ }
+
+ /**
+ * A threshold of zero excludes the shopper's own hold immediately.
+ */
+ public function test_zero_threshold_excludes_own_hold_immediately() {
+ $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 );
+
+ add_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', '__return_zero' );
+ try {
+ $held = wc_get_held_stock_quantity( $product, 0 );
+ } finally {
+ remove_filter( 'woocommerce_own_stock_hold_exclusion_threshold_minutes', '__return_zero' );
+ }
+
+ $this->assertSame( 0, $held );
+ }
+
+ // ---------------------------------------------------------------------
+ // 3. Ownership sources.
+ // ---------------------------------------------------------------------
+
+ /**
+ * The reported flow: a guest on the block checkout whose draft pointer has
+ * already moved on to a new draft order.
+ */
+ public function test_session_list_covers_a_guest_whose_draft_pointer_moved_on() {
+ $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, wc_get_held_stock_quantity( $product, $new_draft ) );
+ }
+
+ /**
+ * A session list that arrived from somebody else's session confers nothing.
+ *
+ * Core hands session data to a different shopper in two places (cart token
+ * cloning and guest-to-user migration); both change the session customer id
+ * first, which is what the stamp detects.
+ */
+ public function test_borrowed_session_list_confers_no_ownership() {
+ $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, wc_get_held_stock_quantity( $product, 0 ) );
+ }
+
+ /**
+ * A billing email is not an ownership claim.
+ *
+ * The attacker types the victim's address into their own checkout. The
+ * victim's in-flight hold must keep blocking.
+ */
+ public function test_billing_email_confers_no_ownership() {
+ $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, wc_get_held_stock_quantity( $product, 0 ) );
+ }
+
+ /**
+ * A guest who exceeds the session list limit loses the oldest entry, and that
+ * hold blocks again: the failure direction is blocking, never overselling.
+ */
+ public function test_own_holds_beyond_the_session_list_limit_block_again() {
+ $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, wc_get_held_stock_quantity( $product, 0 ) );
+ }
+
+ // ---------------------------------------------------------------------
+ // 4. Contexts with no shopper.
+ // ---------------------------------------------------------------------
+
+ /**
+ * With no session — the admin, WP-CLI, cron — nothing is excluded, even for
+ * a signed in user who happens to have unpaid orders of their own.
+ */
+ public function test_no_session_leaves_the_total_unchanged() {
+ $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, wc_get_held_stock_quantity( $product, 0 ) );
+ }
+
+ // ---------------------------------------------------------------------
+ // 5. The public filter contract.
+ // ---------------------------------------------------------------------
+
+ /**
+ * woocommerce_query_for_reserved_stock still receives a scalar order id, and
+ * still receives the complete core query, exclusion clause included.
+ */
+ public function test_filter_signature_is_unchanged() {
+ $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 {
+ wc_get_held_stock_quantity( $product, 4242 );
+ } 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.
+ // ---------------------------------------------------------------------
+
+ /**
+ * 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
+ }
+}