Commit 11d1b1aa908 for woocommerce

commit 11d1b1aa9083db6b0888f8d380c1a685bae5c428
Author: Mike Jolley <mike.jolley@me.com>
Date:   Wed May 13 16:05:09 2026 +0100

    Fix deferred email object argument handling (#64820)

    * Fix deferred email object argument handling

    * Reject deferred email objects with invalid IDs

    * Simplify deferred email ID validation

    * Simplify deferred email object argument handling

diff --git a/plugins/woocommerce/changelog/fix-64793-deferred-email-object-args b/plugins/woocommerce/changelog/fix-64793-deferred-email-object-args
new file mode 100644
index 00000000000..8faac5ed49f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-64793-deferred-email-object-args
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve deferred transactional email object arguments when queued through Action Scheduler.
diff --git a/plugins/woocommerce/includes/class-wc-emails.php b/plugins/woocommerce/includes/class-wc-emails.php
index 858c7a575f6..551c4c48652 100644
--- a/plugins/woocommerce/includes/class-wc-emails.php
+++ b/plugins/woocommerce/includes/class-wc-emails.php
@@ -155,11 +155,11 @@ class WC_Emails {
 	 * @return void
 	 */
 	public static function queue_transactional_email( ...$args ) {
-		if ( self::$deferred_queue instanceof DeferredEmailQueue ) {
-			self::$deferred_queue->push( current_filter(), $args );
-		} else {
-			self::send_transactional_email( ...$args );
+		if ( self::$deferred_queue instanceof DeferredEmailQueue && self::$deferred_queue->push( current_filter(), $args ) ) {
+			return;
 		}
+
+		self::send_transactional_email( ...$args );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Email/DeferredEmailQueue.php b/plugins/woocommerce/src/Internal/Email/DeferredEmailQueue.php
index 9a15b58c5ab..3e831ad3f0d 100644
--- a/plugins/woocommerce/src/Internal/Email/DeferredEmailQueue.php
+++ b/plugins/woocommerce/src/Internal/Email/DeferredEmailQueue.php
@@ -4,6 +4,9 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Internal\Email;

+use Automattic\WooCommerce\Internal\StockNotifications\Factory as StockNotificationFactory;
+use Automattic\WooCommerce\Internal\StockNotifications\Notification as StockNotification;
+
 /**
  * Handles deferred transactional email sending via Action Scheduler.
  *
@@ -25,6 +28,11 @@ final class DeferredEmailQueue {
 	 */
 	private const AS_GROUP = 'woocommerce-emails';

+	/**
+	 * Key for object references stored in queued email args.
+	 */
+	private const QUEUED_OBJECT_KEY = '__woocommerce_deferred_email_object';
+
 	/**
 	 * Queue of email callbacks collected during the current request.
 	 *
@@ -53,10 +61,20 @@ final class DeferredEmailQueue {
 	/**
 	 * Add an email callback to the queue.
 	 *
+	 * Returns false when any argument cannot be represented in Action Scheduler
+	 * storage, allowing callers to fall back to sending the email synchronously.
+	 *
 	 * @param string $filter The action hook name that triggered the email.
 	 * @param array  $args   The arguments passed to the action hook.
+	 * @return bool True if the email was queued.
 	 */
-	public function push( string $filter, array $args ): void {
+	public function push( string $filter, array $args ): bool {
+		try {
+			$args = $this->prepare_arg_for_queue( $args );
+		} catch ( \UnexpectedValueException $e ) {
+			return false;
+		}
+
 		$this->queue[] = array(
 			'filter' => $filter,
 			'args'   => $args,
@@ -66,6 +84,8 @@ final class DeferredEmailQueue {
 			add_action( 'shutdown', array( $this, 'dispatch' ), 100 );
 			$this->shutdown_registered = true;
 		}
+
+		return true;
 	}

 	/**
@@ -82,7 +102,11 @@ final class DeferredEmailQueue {
 		}

 		foreach ( $this->queue as $item ) {
-			\WC()->queue()->add( self::AS_HOOK, array( $item['filter'], $item['args'] ), self::AS_GROUP );
+			\WC()->queue()->add(
+				self::AS_HOOK,
+				array( $item['filter'], $item['args'] ),
+				self::AS_GROUP
+			);
 		}

 		$this->queue               = array();
@@ -102,6 +126,165 @@ final class DeferredEmailQueue {
 			return;
 		}

+		$args = $this->restore_args_from_queue( $args );
+		if ( null === $args ) {
+			return;
+		}
+
 		\WC_Emails::send_queued_transactional_email( $filter, $args );
 	}
+
+	/**
+	 * Convert a queued argument to a JSON-safe value.
+	 *
+	 * @param mixed $arg The argument to convert.
+	 * @return mixed
+	 * @throws \UnexpectedValueException When a queued object argument cannot be prepared.
+	 */
+	private function prepare_arg_for_queue( $arg ) {
+		if ( is_array( $arg ) ) {
+			foreach ( $arg as $key => $value ) {
+				$arg[ $key ] = $this->prepare_arg_for_queue( $value );
+			}
+
+			return $arg;
+		}
+
+		if ( is_object( $arg ) ) {
+			foreach ( $this->get_supported_object_types() as $type => $object_type ) {
+				if ( ! $arg instanceof $object_type['class'] ) {
+					continue;
+				}
+
+				$id = $object_type['get_id']( $arg );
+
+				if ( empty( $id ) || ( ! is_int( $id ) && ! is_string( $id ) ) ) {
+					throw new \UnexpectedValueException( 'Queued email object argument cannot be prepared.' );
+				}
+
+				return array(
+					self::QUEUED_OBJECT_KEY => array(
+						'type' => $type,
+						'id'   => $id,
+					),
+				);
+			}
+
+			throw new \UnexpectedValueException( 'Queued email object argument cannot be prepared.' );
+		}
+
+		return $arg;
+	}
+
+	/**
+	 * Restore queued arguments after Action Scheduler storage.
+	 *
+	 * @param array $args The arguments for the email callback.
+	 * @return array|null
+	 */
+	private function restore_args_from_queue( array $args ): ?array {
+		try {
+			foreach ( $args as $key => $arg ) {
+				$args[ $key ] = $this->restore_arg_from_queue( $arg );
+			}
+
+			return $args;
+		} catch ( \UnexpectedValueException $e ) {
+			return null;
+		}
+	}
+
+	/**
+	 * Restore a queued argument after Action Scheduler storage.
+	 *
+	 * @param mixed $arg The argument to restore.
+	 * @return mixed
+	 * @throws \UnexpectedValueException When a queued object reference cannot be restored.
+	 */
+	private function restore_arg_from_queue( $arg ) {
+		if ( ! is_array( $arg ) ) {
+			return $arg;
+		}
+
+		if ( ! array_key_exists( self::QUEUED_OBJECT_KEY, $arg ) ) {
+			foreach ( $arg as $key => $value ) {
+				$arg[ $key ] = $this->restore_arg_from_queue( $value );
+			}
+
+			return $arg;
+		}
+
+		$reference = $arg[ self::QUEUED_OBJECT_KEY ];
+
+		if ( ! is_array( $reference ) || ! isset( $reference['type'], $reference['id'] ) ) {
+			throw new \UnexpectedValueException( 'Queued email object reference is invalid.' );
+		}
+
+		$id = $reference['id'];
+
+		if ( ! is_int( $id ) && ! is_string( $id ) ) {
+			throw new \UnexpectedValueException( 'Queued email object reference is invalid.' );
+		}
+
+		$object_type = $this->get_supported_object_types()[ (string) $reference['type'] ] ?? null;
+
+		if ( ! is_array( $object_type ) ) {
+			throw new \UnexpectedValueException( 'Queued email object reference is invalid.' );
+		}
+
+		$object = $object_type['fetch']( $id );
+
+		if ( ! is_object( $object ) ) {
+			throw new \UnexpectedValueException( 'Queued email object reference cannot be restored.' );
+		}
+
+		return $object;
+	}
+
+	/**
+	 * Get supported queued object types.
+	 *
+	 * @return array<string, array{class: class-string, get_id: callable, fetch: callable}>
+	 */
+	private function get_supported_object_types(): array {
+		return array(
+			'product'            => array(
+				'class'  => \WC_Product::class,
+				'get_id' => static function ( $queued_object ) {
+					return $queued_object instanceof \WC_Product ? $queued_object->get_id() : null;
+				},
+				'fetch'  => static function ( $id ) {
+					return \WC()->call_function( 'wc_get_product', $id );
+				},
+			),
+			'order'              => array(
+				'class'  => \WC_Order::class,
+				'get_id' => static function ( $queued_object ) {
+					return $queued_object instanceof \WC_Order ? $queued_object->get_id() : null;
+				},
+				'fetch'  => static function ( $id ) {
+					return \WC()->call_function( 'wc_get_order', $id );
+				},
+			),
+			'payment_gateway'    => array(
+				'class'  => \WC_Payment_Gateway::class,
+				'get_id' => static function ( $queued_object ) {
+					return $queued_object instanceof \WC_Payment_Gateway ? $queued_object->id : null;
+				},
+				'fetch'  => static function ( $id ) {
+					$gateways = \WC()->payment_gateways()->payment_gateways();
+					return $gateways[ $id ] ?? null;
+				},
+			),
+			'stock_notification' => array(
+				'class'  => StockNotification::class,
+				'get_id' => static function ( $queued_object ) {
+					return $queued_object instanceof StockNotification ? $queued_object->get_id() : null;
+				},
+				'fetch'  => static function ( $id ) {
+					return StockNotificationFactory::get_notification( (int) $id );
+				},
+			),
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Email/DeferredEmailQueueTest.php b/plugins/woocommerce/tests/php/src/Internal/Email/DeferredEmailQueueTest.php
index ba6178bb308..5ccb2a4deb9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Email/DeferredEmailQueueTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Email/DeferredEmailQueueTest.php
@@ -4,6 +4,8 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Internal\Email;

 use Automattic\WooCommerce\Internal\Email\DeferredEmailQueue;
+use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
+use Automattic\WooCommerce\Internal\StockNotifications\Notification as StockNotification;
 use WC_Unit_Test_Case;

 /**
@@ -18,6 +20,13 @@ class DeferredEmailQueueTest extends WC_Unit_Test_Case {
 	 */
 	private $sut;

+	/**
+	 * Stock notification IDs created by this test class.
+	 *
+	 * @var int[]
+	 */
+	private $created_stock_notification_ids = array();
+
 	/**
 	 * Set up test fixtures.
 	 */
@@ -40,6 +49,12 @@ class DeferredEmailQueueTest extends WC_Unit_Test_Case {
 		remove_all_filters( 'woocommerce_queue_class' );
 		remove_all_filters( 'woocommerce_allow_send_queued_transactional_email' );
 		remove_all_actions( 'woocommerce_send_queued_transactional_email' );
+		remove_all_actions( 'woocommerce_deferred_email_test_unknown_object' );
+		remove_all_actions( 'woocommerce_deferred_email_test_unknown_object_notification' );
+		remove_all_actions( 'woocommerce_deferred_email_test_unsaved_product' );
+		remove_all_actions( 'woocommerce_deferred_email_test_unsaved_product_notification' );
+		$this->set_wc_emails_deferred_queue( null );
+		$this->delete_stock_notifications();
 		$this->reset_queue_singleton();
 		parent::tearDown();
 	}
@@ -98,6 +113,241 @@ class DeferredEmailQueueTest extends WC_Unit_Test_Case {
 		$this->assertSame( array( 42, 'extra' ), $action['args'][1] );
 	}

+	/**
+	 * @testdox Processing preserves null arguments.
+	 */
+	public function test_send_queued_transactional_email_preserves_null_args(): void {
+		$args = array(
+			null,
+			array(
+				'nested' => null,
+			),
+		);
+
+		$this->sut->push( 'woocommerce_order_status_pending_to_processing', $args );
+		$this->sut->dispatch();
+
+		$queue             = $this->get_test_queue();
+		$round_tripped_arg = json_decode( wp_json_encode( $queue->actions[0]['args'] ), true );
+
+		$this->assertIsArray( $round_tripped_arg );
+
+		$sent = array();
+		$this->capture_sent_queued_emails( $sent );
+
+		$this->sut->send_queued_transactional_email(
+			$round_tripped_arg[0],
+			$round_tripped_arg[1]
+		);
+
+		$this->assertCount( 1, $sent, 'Should process the email callback' );
+		$this->assertSame( $args, $sent[0]['args'] );
+	}
+
+	/**
+	 * @testdox Push accepts scalar, array, and known WooCommerce object arguments.
+	 */
+	public function test_push_accepts_supported_args(): void {
+		$product            = \WC_Helper_Product::create_simple_product();
+		$order              = \WC_Helper_Order::create_order();
+		$gateway            = $this->get_test_payment_gateway();
+		$stock_notification = $this->create_test_stock_notification();
+
+		$this->assertTrue(
+			$this->sut->push(
+				'woocommerce_low_stock',
+				array(
+					$product,
+					array(
+						'order'    => $order,
+						'quantity' => 2,
+					),
+					$gateway,
+					$stock_notification,
+				)
+			),
+			'Known WooCommerce objects should be deferable'
+		);
+	}
+
+	/**
+	 * @testdox Push rejects unknown object arguments.
+	 */
+	public function test_push_rejects_unknown_object_args(): void {
+		$this->assertFalse(
+			$this->sut->push( 'woocommerce_low_stock', array( new \stdClass() ) ),
+			'Unknown object args should not be deferable'
+		);
+		$this->assertFalse(
+			$this->sut->push( 'woocommerce_low_stock', array( 'nested' => array( new \stdClass() ) ) ),
+			'Nested unknown object args should not be deferable'
+		);
+	}
+
+	/**
+	 * @testdox Queue transactional email sends synchronously when args cannot be deferred.
+	 */
+	public function test_queue_transactional_email_sends_synchronously_when_args_cannot_be_deferred(): void {
+		$object = new \stdClass();
+		$sent   = array();
+
+		$this->set_wc_emails_deferred_queue( $this->sut );
+
+		// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingHookComment,WooCommerce.Commenting.CommentHooks.MissingSinceComment -- Test-only hooks.
+		add_action( 'woocommerce_deferred_email_test_unknown_object', array( \WC_Emails::class, 'queue_transactional_email' ) );
+		add_action(
+			'woocommerce_deferred_email_test_unknown_object_notification',
+			function ( $arg ) use ( &$sent ) {
+				$sent[] = $arg;
+			}
+		);
+
+		do_action( 'woocommerce_deferred_email_test_unknown_object', $object );
+		// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingHookComment,WooCommerce.Commenting.CommentHooks.MissingSinceComment
+
+		$this->sut->dispatch();
+
+		$queue = $this->get_test_queue();
+
+		$this->assertEmpty( $queue->actions, 'Unsupported object args should not be scheduled' );
+		$this->assertSame( array( $object ), $sent, 'Unsupported object args should be sent synchronously' );
+	}
+
+	/**
+	 * @testdox Queue transactional email sends synchronously when a supported object has no restorable ID.
+	 */
+	public function test_queue_transactional_email_sends_synchronously_when_supported_object_has_no_restorable_id(): void {
+		$product = new \WC_Product_Simple();
+		$sent    = array();
+
+		$this->set_wc_emails_deferred_queue( $this->sut );
+
+		// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingHookComment,WooCommerce.Commenting.CommentHooks.MissingSinceComment -- Test-only hooks.
+		add_action( 'woocommerce_deferred_email_test_unsaved_product', array( \WC_Emails::class, 'queue_transactional_email' ) );
+		add_action(
+			'woocommerce_deferred_email_test_unsaved_product_notification',
+			function ( $arg ) use ( &$sent ) {
+				$sent[] = $arg;
+			}
+		);
+
+		do_action( 'woocommerce_deferred_email_test_unsaved_product', $product );
+		// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingHookComment,WooCommerce.Commenting.CommentHooks.MissingSinceComment
+
+		$this->sut->dispatch();
+
+		$queue = $this->get_test_queue();
+
+		$this->assertSame( 0, $product->get_id() );
+		$this->assertEmpty( $queue->actions, 'Supported object args with no restorable ID should not be scheduled' );
+		$this->assertSame( array( $product ), $sent, 'Supported object args with no restorable ID should be sent synchronously' );
+	}
+
+	/**
+	 * @testdox Push rejects items with object arguments that cannot be prepared for storage.
+	 */
+	public function test_push_rejects_items_with_unprepared_object_args(): void {
+		$this->assertFalse( $this->sut->push( 'woocommerce_low_stock', array( new \stdClass() ) ) );
+		$this->assertFalse( $this->sut->push( 'woocommerce_low_stock', array( new \WC_Product_Simple() ) ) );
+
+		$this->sut->dispatch();
+
+		$queue = $this->get_test_queue();
+
+		$this->assertEmpty( $queue->actions, 'Unsupported object args should not be scheduled' );
+	}
+
+	/**
+	 * @testdox Dispatch preserves product arguments after Action Scheduler JSON serialization.
+	 */
+	public function test_dispatch_preserves_product_args_after_action_scheduler_json_round_trip(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+
+		$this->assert_object_args_round_trip(
+			'woocommerce_low_stock',
+			array( $product ),
+			0,
+			'product',
+			\WC_Product::class,
+			$product->get_id()
+		);
+	}
+
+	/**
+	 * @testdox Dispatch preserves order arguments after Action Scheduler JSON serialization.
+	 */
+	public function test_dispatch_preserves_order_args_after_action_scheduler_json_round_trip(): void {
+		$order = \WC_Helper_Order::create_order();
+
+		$this->assert_object_args_round_trip(
+			'woocommerce_order_status_completed',
+			array( $order->get_id(), $order ),
+			1,
+			'order',
+			\WC_Order::class,
+			$order->get_id()
+		);
+	}
+
+	/**
+	 * @testdox Dispatch preserves payment gateway arguments after Action Scheduler JSON serialization.
+	 */
+	public function test_dispatch_preserves_payment_gateway_args_after_action_scheduler_json_round_trip(): void {
+		$gateway = $this->get_test_payment_gateway();
+
+		$this->assert_object_args_round_trip(
+			'woocommerce_payment_gateway_enabled',
+			array( $gateway ),
+			0,
+			'payment_gateway',
+			\WC_Payment_Gateway::class,
+			$gateway->id
+		);
+	}
+
+	/**
+	 * @testdox Dispatch preserves stock notification arguments after Action Scheduler JSON serialization.
+	 */
+	public function test_dispatch_preserves_stock_notification_args_after_action_scheduler_json_round_trip(): void {
+		$notification = $this->create_test_stock_notification();
+
+		$this->assert_object_args_round_trip(
+			'woocommerce_customer_stock_notification_verified',
+			array( $notification ),
+			0,
+			'stock_notification',
+			StockNotification::class,
+			$notification->get_id()
+		);
+	}
+
+	/**
+	 * @testdox Dispatch skips email when a queued product reference can no longer be restored.
+	 */
+	public function test_dispatch_skips_email_when_queued_object_reference_cannot_be_restored(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+
+		$this->sut->push( 'woocommerce_low_stock', array( $product ) );
+		$this->sut->dispatch();
+
+		$queue             = $this->get_test_queue();
+		$round_tripped_arg = json_decode( wp_json_encode( $queue->actions[0]['args'] ), true );
+
+		$this->assertIsArray( $round_tripped_arg );
+
+		\WC_Helper_Product::delete_product( $product->get_id() );
+
+		$sent = array();
+		$this->capture_sent_queued_emails( $sent );
+
+		$this->sut->send_queued_transactional_email(
+			$round_tripped_arg[0],
+			$round_tripped_arg[1]
+		);
+
+		$this->assertEmpty( $sent, 'Email should be skipped when a queued object reference cannot be restored' );
+	}
+
 	/**
 	 * @testdox Dispatch assigns the woocommerce-emails group to scheduled actions.
 	 */
@@ -195,4 +445,184 @@ class DeferredEmailQueueTest extends WC_Unit_Test_Case {
 		$this->assertInstanceOf( \WC_Admin_Test_Action_Queue::class, $queue );
 		return $queue;
 	}
+
+	/**
+	 * Push an email, assert the wrapped object reference is scheduled, JSON
+	 * round-trip the scheduled args, and assert the email callback receives
+	 * the restored object back.
+	 *
+	 * @param string     $filter           Email hook name.
+	 * @param array      $args             Args to push.
+	 * @param int        $wrapped_position Index of the object argument inside $args.
+	 * @param string     $expected_type    Expected object reference type.
+	 * @param string     $expected_class   Expected restored object class.
+	 * @param int|string $expected_id      Expected restored object ID.
+	 */
+	private function assert_object_args_round_trip(
+		string $filter,
+		array $args,
+		int $wrapped_position,
+		string $expected_type,
+		string $expected_class,
+		$expected_id
+	): void {
+		$this->sut->push( $filter, $args );
+		$this->sut->dispatch();
+
+		$queue         = $this->get_test_queue();
+		$scheduled_arg = $queue->actions[0]['args'];
+		$encoded_arg   = wp_json_encode( $scheduled_arg );
+
+		$this->assert_queued_object_reference( $scheduled_arg[1][ $wrapped_position ], $expected_type, $expected_id );
+		$this->assertIsString( $encoded_arg );
+
+		$round_tripped_arg = json_decode( $encoded_arg, true );
+		$this->assertIsArray( $round_tripped_arg );
+
+		$sent = array();
+		$this->capture_sent_queued_emails( $sent );
+
+		$this->sut->send_queued_transactional_email(
+			$round_tripped_arg[0],
+			$round_tripped_arg[1]
+		);
+
+		$this->assertCount( 1, $sent, 'Should process the email callback' );
+		$this->assertSame( $filter, $sent[0]['filter'] );
+		$this->assertInstanceOf( $expected_class, $sent[0]['args'][ $wrapped_position ] );
+		$this->assertSame( $expected_id, $this->get_restored_object_id( $sent[0]['args'][ $wrapped_position ] ) );
+	}
+
+	/**
+	 * Get an ID from a restored queued object.
+	 *
+	 * @param object $restored_object Restored queued object.
+	 * @return int|string
+	 */
+	private function get_restored_object_id( object $restored_object ) {
+		if ( $restored_object instanceof \WC_Payment_Gateway ) {
+			return $restored_object->id;
+		}
+
+		return $restored_object->get_id();
+	}
+
+	/**
+	 * Register a filter that captures emails reaching send_queued_transactional_email
+	 * into the given accumulator, short-circuiting actual sending.
+	 *
+	 * @param array $sent Accumulator for captured emails (passed by reference).
+	 */
+	private function capture_sent_queued_emails( array &$sent ): void {
+		add_filter(
+			'woocommerce_allow_send_queued_transactional_email',
+			function ( $allow, $filter, $args ) use ( &$sent ) {
+				unset( $allow );
+				$sent[] = array(
+					'filter' => $filter,
+					'args'   => $args,
+				);
+				return false;
+			},
+			10,
+			3
+		);
+	}
+
+	/**
+	 * Assert a queued object reference has the expected wrapper shape.
+	 *
+	 * @param array      $reference Queued object reference.
+	 * @param string     $type      Expected object type.
+	 * @param int|string $id        Expected object ID.
+	 */
+	private function assert_queued_object_reference( array $reference, string $type, $id ): void {
+		$this->assertSame(
+			array(
+				'__woocommerce_deferred_email_object' => array(
+					'type' => $type,
+					'id'   => $id,
+				),
+			),
+			$reference
+		);
+	}
+
+	/**
+	 * Set the deferred queue used by WC_Emails.
+	 *
+	 * @param DeferredEmailQueue|null $queue Deferred email queue instance.
+	 */
+	private function set_wc_emails_deferred_queue( ?DeferredEmailQueue $queue ): void {
+		$reflection     = new \ReflectionClass( \WC_Emails::class );
+		$deferred_queue = $reflection->getProperty( 'deferred_queue' );
+		$deferred_queue->setAccessible( true );
+		$deferred_queue->setValue( null, $queue );
+	}
+
+	/**
+	 * Get a payment gateway available in the test environment.
+	 *
+	 * @return \WC_Payment_Gateway
+	 */
+	private function get_test_payment_gateway(): \WC_Payment_Gateway {
+		$gateways = \WC()->payment_gateways()->payment_gateways();
+		$gateway  = $gateways['bacs'] ?? reset( $gateways );
+
+		if ( ! $gateway instanceof \WC_Payment_Gateway ) {
+			$this->fail( 'Expected at least one payment gateway to be available' );
+		}
+
+		return $gateway;
+	}
+
+	/**
+	 * Create a stock notification available in the test environment.
+	 *
+	 * @return StockNotification
+	 */
+	private function create_test_stock_notification(): StockNotification {
+		$product      = \WC_Helper_Product::create_simple_product();
+		$notification = new StockNotification();
+
+		$notification->set_product_id( $product->get_id() );
+		$notification->set_user_email( 'customer@example.com' );
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->save();
+
+		$this->created_stock_notification_ids[] = (int) $notification->get_id();
+
+		return $notification;
+	}
+
+	/**
+	 * Delete stock notifications created by these tests.
+	 */
+	private function delete_stock_notifications(): void {
+		global $wpdb;
+
+		if ( empty( $this->created_stock_notification_ids ) ) {
+			return;
+		}
+
+		$ids              = array_map( 'absint', $this->created_stock_notification_ids );
+		$ids_placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
+
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $ids_placeholders contains %d placeholders for sanitized IDs.
+		$wpdb->query(
+			$wpdb->prepare(
+				"DELETE FROM {$wpdb->prefix}wc_stock_notificationmeta WHERE notification_id IN ({$ids_placeholders})",
+				...$ids
+			)
+		);
+		$wpdb->query(
+			$wpdb->prepare(
+				"DELETE FROM {$wpdb->prefix}wc_stock_notifications WHERE id IN ({$ids_placeholders})",
+				...$ids
+			)
+		);
+		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+
+		$this->created_stock_notification_ids = array();
+	}
 }