Commit e8990a4aaec for woocommerce

commit e8990a4aaec665470e444f6bb60c92f043c89c67
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Thu Sep 24 17:18:31 2026 +0100

    Send the correct stock notification when a failed send is retried (#68505)

    * Carry the stock event type through push notification retries

    The retry job stored only the type, resource ID and attempt number, so
    handle_retry() rebuilt every stock notification with the constructor's
    low_stock default. Because the delivery state meta keys are scoped by
    event type, a retried out-of-stock send read and wrote the low_stock
    markers, which let it send a notification that was already sent and
    cleared the state of a different notification for the same product. It
    also sent low stock wording for an out-of-stock event.

    Action Scheduler matches the unique flag on the serialized arguments, so
    the missing event type also meant two stock events for one product
    produced identical arguments and only the first was ever scheduled.

    The event type moves into Notification::get_identity_data() so the retry
    path and the safety net take it from the same place. It is appended to
    the arguments only when a notification has some, which keeps the list
    unchanged for orders and reviews and lets a retry scheduled before this
    shipped still deduplicate. handle_retry() defaults the new parameter to
    an empty array so those in-flight retries continue to run.

    * Guard the identity data against volatile state

    get_safety_net_args() now derives from get_identity_data() in the base class,
    so both Action Scheduler paths take identity from one place. The arguments are
    unchanged for all three current types. A subclass returning anything that
    varies between two instances of the same notification would stop the cancel
    call and the retry dedupe guard matching, leaving an uncancelled safety net to
    re-send, so a test over NOTIFICATION_CLASSES enforces it. Also corrects the
    comment that claimed the dedupe guard survives a deploy for every type, which
    holds only for orders and reviews.

    * Pin the safety-net arguments for each notification type

    The schedule and cancel paths both build their match key from
    get_safety_net_args(), so a change there moves them together and the existing
    tests stay green while every stock safety net silently loses its event type.
    Assert the literal arguments instead, driven off the same provider as the
    identity data test so a new subclass has to declare them.

    * Dispatch the stock retry test through the registered hook

    * Name the event type in the stock identity data docblock

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
index ed1b857458a..40c8e554190 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
@@ -293,6 +293,20 @@ abstract class Notification {
 		return $this->resource_id;
 	}

+	/**
+	 * Extra fields that identify this notification beyond its type and resource
+	 * ID, in the form {@see self::from_array()} accepts.
+	 *
+	 * Identity only, for the reasons {@see self::get_safety_net_args()} gives.
+	 *
+	 * @return array<string, mixed>
+	 *
+	 * @since 11.2.0
+	 */
+	public function get_identity_data(): array {
+		return array();
+	}
+
 	/**
 	 * Canonical positional ActionScheduler arguments for the safety-net job.
 	 *
@@ -312,7 +326,16 @@ abstract class Notification {
 	 * @since 10.9.0
 	 */
 	public function get_safety_net_args(): array {
-		return array( $this->get_type(), $this->get_resource_id() );
+		$args          = array( $this->get_type(), $this->get_resource_id() );
+		$identity_data = $this->get_identity_data();
+
+		// Skipped when empty so an in-flight safety net for a type without
+		// identity data still cancels.
+		if ( ! empty( $identity_data ) ) {
+			$args[] = $identity_data;
+		}
+
+		return $args;
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php
index 881a5643431..5f31b3669e7 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php
@@ -156,27 +156,16 @@ class StockNotification extends Notification {
 	/**
 	 * {@inheritDoc}
 	 *
-	 * Appends `event_type` because it is part of this notification's identity
-	 * (see {@see self::get_identifier()}): the same product can have distinct
-	 * low_stock / out_of_stock / on_backorder safety nets pending at once, and
-	 * the callback needs it to reconstruct the correct subtype.
+	 * Without the event type, a rebuilt stock notification falls back to the
+	 * constructor's low_stock default and reads and writes another event's
+	 * delivery state.
 	 *
-	 * `stock_quantity_at_trigger` is deliberately omitted — it is volatile
-	 * payload data, not identity, and does not round-trip through every cancel
-	 * path, so including it in the match key would risk breaking cancellation.
-	 * The safety-net fallback message reads current product stock when it is
-	 * absent (see {@see self::build_message()}).
+	 * @return array{event_type: string}
 	 *
-	 * @return array<int, mixed>
-	 *
-	 * @since 10.9.0
+	 * @since 11.2.0
 	 */
-	public function get_safety_net_args(): array {
-		return array(
-			$this->get_type(),
-			$this->get_resource_id(),
-			array( 'event_type' => $this->event_type ),
-		);
+	public function get_identity_data(): array {
+		return array( 'event_type' => $this->event_type );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
index acf1d42bfe6..e630110d0a9 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
@@ -281,7 +281,7 @@ class NotificationProcessor {
 	 *
 	 * @param string $type        The notification type.
 	 * @param int    $resource_id The resource ID.
-	 * @param array  $extra       Optional subclass-specific extras (e.g. event_type, stock_quantity_at_trigger).
+	 * @param array  $extra       Identity fields from {@see Notification::get_identity_data()}.
 	 *                            Empty for notification types whose state is fully described by type + resource_id.
 	 * @return void
 	 *
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php
index 2413477ba09..c52ce7a454d 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php
@@ -63,7 +63,7 @@ class NotificationRetryHandler {
 	 * @since 10.8.0
 	 */
 	public function register(): void {
-		add_action( self::RETRY_HOOK, array( $this, 'handle_retry' ), 10, 3 );
+		add_action( self::RETRY_HOOK, array( $this, 'handle_retry' ), 10, 4 );
 	}

 	/**
@@ -119,14 +119,29 @@ class NotificationRetryHandler {
 			return;
 		}

+		// Action Scheduler dispatches array_values( $args ), so these keys are
+		// decorative and the order alone decides which handle_retry() parameter
+		// each value lands in.
+		$args = array(
+			'type'        => $notification->get_type(),
+			'resource_id' => $notification->get_resource_id(),
+			'attempt'     => $next_attempt,
+		);
+
+		$identity_data = $notification->get_identity_data();
+
+		// Appended only when there is any, so orders and reviews keep the exact
+		// argument list they had before this field existed and `$unique` still
+		// matches a retry scheduled before it. Stock retries do change, so
+		// during the deploy both formats can be pending for one product.
+		if ( ! empty( $identity_data ) ) {
+			$args['extra'] = $identity_data;
+		}
+
 		$action_id = as_schedule_single_action(
 			time() + $delay,
 			self::RETRY_HOOK,
-			array(
-				'type'        => $notification->get_type(),
-				'resource_id' => $notification->get_resource_id(),
-				'attempt'     => $next_attempt,
-			),
+			$args,
 			NotificationProcessor::ACTION_SCHEDULER_GROUP,
 			true
 		);
@@ -148,23 +163,29 @@ class NotificationRetryHandler {
 	/**
 	 * ActionScheduler callback for retry jobs.
 	 *
-	 * Reconstructs the notification from the stored type and resource ID,
-	 * then delegates to the processor with is_retry=true.
+	 * Reconstructs the notification from the stored identity, then delegates to
+	 * the processor with is_retry=true.
+	 *
+	 * `$extra` defaults to empty so retries scheduled before it was added still
+	 * run, rebuilding a stock notification as low_stock as they always did.
 	 *
 	 * @param string $type        The notification type.
 	 * @param int    $resource_id The resource ID.
 	 * @param int    $attempt     The current retry attempt number (1-based).
+	 * @param array  $extra       Identity fields from {@see Notification::get_identity_data()}.
 	 * @return void
 	 *
 	 * @since 10.8.0
 	 */
-	public function handle_retry( string $type, int $resource_id, int $attempt ): void {
+	public function handle_retry( string $type, int $resource_id, int $attempt, array $extra = array() ): void {
 		try {
+			// `+` rather than array_merge for the reason given in
+			// NotificationProcessor::handle_safety_net().
 			$notification = Notification::from_array(
 				array(
 					'type'        => $type,
 					'resource_id' => $resource_id,
-				)
+				) + $extra
 			);
 		} catch ( Exception $e ) {
 			wc_get_logger()->error(
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
index 25e73e6c087..6d27bcf8c7e 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
@@ -178,4 +178,78 @@ class NotificationTest extends WC_Unit_Test_Case {
 		$this->assertInstanceOf( NewOrderNotification::class, $notification );
 		$this->assertSame( 42, $notification->get_resource_id() );
 	}
+
+	/**
+	 * Action Scheduler matches the safety-net cancel call and the retry dedupe
+	 * guard on the exact serialized arguments, and both derive from
+	 * get_identity_data(). A subclass that returns anything varying between two
+	 * instances of the same notification would stop those matching, which leaves
+	 * an uncancelled safety net to re-send and a row per trigger in
+	 * wp_actionscheduler_actions.
+	 *
+	 * @testdox Should return identity data that does not vary with volatile constructor state.
+	 * @dataProvider provider_notification_identity_pairs
+	 *
+	 * @param callable $build              Builds a notification from a volatile value.
+	 * @param bool     $has_volatile_state Whether this subclass has volatile state for $build to vary.
+	 */
+	public function test_get_identity_data_ignores_volatile_state( callable $build, bool $has_volatile_state ): void {
+		$one   = $build( 1 );
+		$other = $build( 999 );
+
+		if ( $has_volatile_state ) {
+			$this->assertNotSame(
+				$one->to_array(),
+				$other->to_array(),
+				'The provider case has to vary the volatile state, or the assertion below proves nothing.'
+			);
+		}
+
+		$this->assertSame( $one->get_identity_data(), $other->get_identity_data() );
+	}
+
+	/**
+	 * Action Scheduler matches a scheduled action on its serialized arguments, so
+	 * the schedule and cancel paths agreeing is not enough. Both derive from
+	 * get_safety_net_args(), so a change there moves them together and stays
+	 * green while silently dropping the event type from every stock safety net.
+	 * These are the literal arguments that have to be stored.
+	 *
+	 * @testdox Should build the expected safety-net arguments for each notification subclass.
+	 * @dataProvider provider_notification_identity_pairs
+	 *
+	 * @param callable          $build              Builds a notification from a volatile value.
+	 * @param bool              $has_volatile_state Unused here; see the identity data test.
+	 * @param array<int, mixed> $expected_args      The safety-net arguments this subclass must produce.
+	 */
+	public function test_get_safety_net_args( callable $build, bool $has_volatile_state, array $expected_args ): void {
+		$this->assertSame( $expected_args, $build( 1 )->get_safety_net_args() );
+	}
+
+	/**
+	 * @testdox Should have an identity data case for every notification subclass.
+	 */
+	public function test_identity_data_provider_covers_every_subclass(): void {
+		$this->assertEqualsCanonicalizing(
+			array_keys( Notification::NOTIFICATION_CLASSES ),
+			array_keys( $this->provider_notification_identity_pairs() ),
+			'A new Notification subclass needs a case in the provider, so its identity data is checked too.'
+		);
+	}
+
+	/**
+	 * One entry per Notification subclass: a callable that builds the same
+	 * notification from a volatile value, a flag saying whether the subclass has
+	 * any volatile state for the callable to vary, and the exact safety-net
+	 * arguments Action Scheduler has to store for it.
+	 *
+	 * @return array<string, array{callable, bool, array<int, mixed>}>
+	 */
+	public function provider_notification_identity_pairs(): array {
+		return array(
+			'store_order'  => array( fn() => new NewOrderNotification( 42 ), false, array( 'store_order', 42 ) ),
+			'store_review' => array( fn() => new NewReviewNotification( 42 ), false, array( 'store_review', 42 ) ),
+			'store_stock'  => array( fn( int $volatile ) => new StockNotification( 42, StockNotification::EVENT_LOW_STOCK, $volatile ), true, array( 'store_stock', 42, array( 'event_type' => StockNotification::EVENT_LOW_STOCK ) ) ),
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationRetryHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationRetryHandlerTest.php
index f2a6794898d..c280a15420f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationRetryHandlerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationRetryHandlerTest.php
@@ -8,11 +8,14 @@ use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataS
 use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\WpcomNotificationDispatcher;
 use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
 use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
 use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationPreferencesService;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationRetryHandler;
 use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use stdClass;
+use WC_Helper_Product;
 use WC_Unit_Test_Case;

 /**
@@ -50,6 +53,9 @@ class NotificationRetryHandlerTest extends WC_Unit_Test_Case {
 	 * Tear down test fixtures.
 	 */
 	public function tearDown(): void {
+		// Container first: a throw in the unschedule would otherwise leak the
+		// replacement into every later test in the process.
+		$this->reset_container_replacements();
 		as_unschedule_all_actions( NotificationRetryHandler::RETRY_HOOK );
 		parent::tearDown();
 	}
@@ -236,8 +242,6 @@ class NotificationRetryHandlerTest extends WC_Unit_Test_Case {

 		$order = wc_get_order( $this->order_id );
 		$this->assertNotEmpty( $order->get_meta( NotificationProcessor::SENT_META_KEY ) );
-
-		$this->reset_container_replacements();
 	}

 	/**
@@ -304,4 +308,185 @@ class NotificationRetryHandlerTest extends WC_Unit_Test_Case {
 		$this->assertSame( '', $order->get_meta( NotificationProcessor::TRIGGERED_META_KEY ) );
 		$this->assertLogged( 'error', 'retry could not be scheduled', array( 'source' => PushNotifications::FEATURE_NAME ) );
 	}
+
+	/**
+	 * @testdox Should carry the stock event type in the scheduled retry arguments.
+	 */
+	public function test_schedule_carries_the_stock_event_type(): void {
+		$product      = WC_Helper_Product::create_simple_product();
+		$notification = new StockNotification( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+
+		$this->sut->schedule( $notification, null, 0 );
+
+		$scheduled = as_next_scheduled_action(
+			NotificationRetryHandler::RETRY_HOOK,
+			array(
+				'type'        => 'store_stock',
+				'resource_id' => $product->get_id(),
+				'attempt'     => 1,
+				'extra'       => array( 'event_type' => StockNotification::EVENT_OUT_OF_STOCK ),
+			),
+			NotificationProcessor::ACTION_SCHEDULER_GROUP
+		);
+
+		$this->assertNotFalse( $scheduled, 'The retry should record the event type it was scheduled for.' );
+	}
+
+	/**
+	 * Action Scheduler deduplicates a unique action on its serialized arguments,
+	 * so without the event type a second stock event for the same product would
+	 * match the first and never be scheduled.
+	 *
+	 * @testdox Should schedule a separate retry for each stock event type on one product.
+	 */
+	public function test_schedule_keeps_a_separate_retry_per_stock_event_type(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$this->sut->schedule( new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK ), null, 0 );
+		$this->sut->schedule( new StockNotification( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK ), null, 0 );
+
+		foreach ( array( StockNotification::EVENT_LOW_STOCK, StockNotification::EVENT_OUT_OF_STOCK ) as $event_type ) {
+			$this->assertNotFalse(
+				as_next_scheduled_action(
+					NotificationRetryHandler::RETRY_HOOK,
+					array(
+						'type'        => 'store_stock',
+						'resource_id' => $product->get_id(),
+						'attempt'     => 1,
+						'extra'       => array( 'event_type' => $event_type ),
+					),
+					NotificationProcessor::ACTION_SCHEDULER_GROUP
+				),
+				sprintf( 'A retry should be scheduled for %s.', $event_type )
+			);
+		}
+	}
+
+	/**
+	 * The argument list stays byte-identical for types with no identity data, so
+	 * a retry scheduled before this field existed still deduplicates against one
+	 * scheduled after it.
+	 *
+	 * @testdox Should omit the extra argument for notification types with no identity data.
+	 */
+	public function test_schedule_omits_the_extra_argument_for_simple_types(): void {
+		$notification = new NewOrderNotification( $this->order_id );
+
+		$this->sut->schedule( $notification, null, 0 );
+
+		$this->assertNotFalse(
+			as_next_scheduled_action(
+				NotificationRetryHandler::RETRY_HOOK,
+				array(
+					'type'        => 'store_order',
+					'resource_id' => $this->order_id,
+					'attempt'     => 1,
+				),
+				NotificationProcessor::ACTION_SCHEDULER_GROUP
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should rebuild the retried notification with the stock event type it was scheduled for.
+	 */
+	public function test_handle_retry_rebuilds_the_stock_event_type(): void {
+		$product  = WC_Helper_Product::create_simple_product();
+		$captured = $this->stub_processor_with_successful_dispatch();
+
+		$this->sut->register();
+		do_action(
+			NotificationRetryHandler::RETRY_HOOK,
+			'store_stock',
+			$product->get_id(),
+			1,
+			array( 'event_type' => StockNotification::EVENT_OUT_OF_STOCK )
+		);
+
+		$this->assertSame( StockNotification::EVENT_OUT_OF_STOCK, $captured->notification->get_event_type() );
+
+		$refreshed = wc_get_product( $product->get_id() );
+		$this->assertNotEmpty( $refreshed->get_meta( NotificationProcessor::SENT_META_KEY . '_out_of_stock' ) );
+		$this->assertEmpty( $refreshed->get_meta( NotificationProcessor::SENT_META_KEY . '_low_stock' ) );
+	}
+
+	/**
+	 * @testdox Should ignore type and resource_id keys smuggled into the extra argument.
+	 */
+	public function test_handle_retry_extra_cannot_override_positional_params(): void {
+		$product  = WC_Helper_Product::create_simple_product();
+		$captured = $this->stub_processor_with_successful_dispatch();
+
+		$this->sut->handle_retry(
+			'store_stock',
+			$product->get_id(),
+			1,
+			array(
+				'event_type'  => StockNotification::EVENT_LOW_STOCK,
+				'type'        => 'store_order',
+				'resource_id' => 999999,
+			)
+		);
+
+		$this->assertSame( 'store_stock', $captured->notification->get_type() );
+		$this->assertSame( $product->get_id(), $captured->notification->get_resource_id() );
+		$this->assertSame( StockNotification::EVENT_LOW_STOCK, $captured->notification->get_event_type() );
+	}
+
+	/**
+	 * Replaces the container's processor with one whose dispatcher reports
+	 * success, so a retry runs end to end and the notification it was handed
+	 * can be inspected.
+	 *
+	 * @return stdClass Holder whose `notification` property receives the dispatched notification.
+	 */
+	private function stub_processor_with_successful_dispatch(): stdClass {
+		$captured               = new stdClass();
+		$captured->notification = null;
+
+		$dispatcher          = $this->createMock( WpcomNotificationDispatcher::class );
+		$data_store          = $this->createMock( PushTokensDataStore::class );
+		$preferences_service = $this->createMock( NotificationPreferencesService::class );
+
+		$preferences_service->method( 'get_preferences' )->willReturn( array() );
+
+		$dispatcher
+			->expects( $this->once() )
+			->method( 'dispatch' )
+			->with(
+				$this->callback(
+					function ( $notification ) use ( $captured ) {
+						$captured->notification = $notification;
+						return true;
+					}
+				)
+			)
+			->willReturn(
+				array(
+					'success'     => true,
+					'retry_after' => null,
+				)
+			);
+
+		$data_store->method( 'get_tokens_for_roles' )->willReturn(
+			array(
+				new PushToken(
+					array(
+						'user_id'       => 1,
+						'token'         => 'test-token',
+						'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+						'platform'      => PushToken::PLATFORM_APPLE,
+						'device_locale' => 'en_US',
+						'device_uuid'   => 'test-uuid',
+					)
+				),
+			)
+		);
+
+		$processor = new NotificationProcessor();
+		$processor->init( $dispatcher, $data_store, $preferences_service, $this->createMock( NotificationRetryHandler::class ) );
+		wc_get_container()->replace( NotificationProcessor::class, $processor );
+
+		return $captured;
+	}
 }