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();
+ }
}