Commit decfa3a6637 for woocommerce
commit decfa3a663789dd021e8fe098aa8c4fa1f293701
Author: Adam Grzybkowski <agrzybkowski@outlook.com>
Date: Thu May 28 14:25:45 2026 +0200
Add store_stock push notification for low/out/backorder events (#64600)
* Add StockNotification type for store_stock push notifications
Introduces a polymorphic StockNotification class with three event
subtypes (low_stock, out_of_stock, on_backorder) keyed off a single
store_stock entry in NOTIFICATION_CLASSES. The same product can have
distinct notifications for different stock events in flight at the
same time because get_identifier() includes the event type.
For variations, meta.product_id resolves to the parent product ID so
the mobile app can navigate to the product details screen.
resource_id keeps the actual entity ID (variation or simple product)
so per-event identifier and dedup logic still distinguish individual
variations.
Stock quantity is captured at construction time and threaded through
to_array/hydrate so the low_stock message renders the trigger-time
value rather than re-fetching the product at dispatch — which runs
in a separate process and can return stale cached stock.
Notification::from_array() gains an optional hydrate() hook so
subclasses can restore extra state that the base constructor cannot
receive. hydrate() throws on unrecognized event_type so corrupt
safety-net jobs are dropped instead of dispatching the wrong subtype.
Linear: RSM-1551
* Add stock notification trigger and register on stock events
Listens on woocommerce_low_stock, woocommerce_no_stock and
woocommerce_product_on_backorder, mapping each to its corresponding
StockNotification event subtype and adding it to the pending store
for shutdown dispatch.
The low_stock handler captures get_stock_quantity() at trigger time
and passes it to the notification so the dispatcher (which runs in a
separate process) doesn't have to re-fetch the product and risk
reading a stale value.
Linear: RSM-1551
* Add store_stock REST schema, preferences and safety-net round-trip
Extends the preferences contract with store_stock sub-flags
(low_stock, out_of_stock, on_backorder), defaulting on_backorder to
false so backorder events are opt-in. The REST schema validates the
sub-flags as booleans and the service drops unknown sub-fields.
PendingNotificationStore now schedules the safety-net via
$notification->to_array() so subclass state (event_type, stock
quantity snapshot) survives serialization. NotificationProcessor
::handle_safety_net accepts optional third (event_type) and fourth
(stock_quantity_at_trigger) parameters; the defaults keep any
in-flight 2-arg actions working.
Linear: RSM-1551
* Add changelog entry for store_stock push notification
* Pass safety-net extras as an array param
Replace the growing positional param list on
NotificationProcessor::handle_safety_net (type, resource_id, event_type,
stock_quantity_at_trigger) with a single optional `array $extra` so
future notification subclasses can add fields to to_array() without
extending the signature each time.
PendingNotificationStore now splits to_array() into [type, resource_id,
$rest] before scheduling the ActionScheduler action, and the receiver
array_merges the extras back onto type+resource_id when reconstructing
via Notification::from_array.
Addresses review feedback on RSM-1551.
* Guard safety-net extras against overwriting positional params
Switch handle_safety_net's payload construction from array_merge to the
`+` array union operator so the positional $type and $resource_id always
win over any colliding keys in the $extra array. Defends against a
malformed payload reconstructing the wrong notification target.
Addresses CodeRabbit review feedback on RSM-1551.
* Use a warning glyph for stock push notifications
Stock events (low / out / backorder) are merchant alerts — "you
should know about this" — which a warning triangle communicates more
directly than a payment icon. WPCOM's tos-warning-note-icon.png is
already at retina resolution (512x512) despite missing the -2x suffix
in its name, so no resolution trade-off vs the sibling notifications
that use explicitly -2x-named assets.
WPCOM has no stock-specific icon — coordinating a proper retina
stock asset with the design team is tracked as a follow-up.
* Enable on_backorder stock notifications by default
Backorder events are actionable for merchants — selling something
you don't have on hand is usually news worth a push. Defaulting the
sub-flag to true matches the behavior of low_stock and out_of_stock
and aligns with WC core's email defaults (which send backorder
emails by default).
Merchants who run backorder-heavy stores can still opt out via the
preferences REST endpoint.
diff --git a/plugins/woocommerce/changelog/issue-RSM-1551 b/plugins/woocommerce/changelog/issue-RSM-1551
new file mode 100644
index 00000000000..6b0122884de
--- /dev/null
+++ b/plugins/woocommerce/changelog/issue-RSM-1551
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the store_stock push notification covering low-stock, out-of-stock, and backorder events. All three events are enabled by default and each one has its own merchant-configurable sub-flag.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php
index 170351beb79..5478baebc12 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php
@@ -224,6 +224,20 @@ class NotificationPreferencesRestController extends RestApiControllerBase {
);
}
+ $boolean_sub_fields = array( 'low_stock', 'out_of_stock', 'on_backorder' );
+ foreach ( $boolean_sub_fields as $sub_field ) {
+ if ( array_key_exists( $sub_field, $shape ) ) {
+ $properties[ $sub_field ] = array(
+ 'type' => 'boolean',
+ 'description' => sprintf(
+ /* translators: %s: sub-field name (e.g. low_stock). */
+ __( 'Whether %s notifications are enabled for this type.', 'woocommerce' ),
+ $sub_field
+ ),
+ );
+ }
+ }
+
$args[ $key ] = array(
'description' => sprintf(
/* translators: %s: notification preference key (e.g. store_order). */
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
index 1ed640290d7..35404ad7445 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
@@ -25,6 +25,7 @@ abstract class Notification {
const NOTIFICATION_CLASSES = array(
'store_order' => NewOrderNotification::class,
'store_review' => NewReviewNotification::class,
+ 'store_stock' => StockNotification::class,
);
/**
@@ -139,7 +140,13 @@ abstract class Notification {
throw new InvalidArgumentException( sprintf( 'Unknown notification type: %s', $type ) );
}
- return new $class( $resource_id );
+ $instance = new $class( $resource_id );
+
+ if ( method_exists( $instance, 'hydrate' ) ) {
+ $instance->hydrate( $data );
+ }
+
+ return $instance;
}
/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php
new file mode 100644
index 00000000000..42a75799277
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php
@@ -0,0 +1,335 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Notifications;
+
+use InvalidArgumentException;
+use WC_Product;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Notification for product stock events (low stock, out of stock, backorder).
+ *
+ * @since 10.9.0
+ */
+class StockNotification extends Notification {
+ const TYPE = 'store_stock';
+
+ const EVENT_LOW_STOCK = 'low_stock';
+ const EVENT_OUT_OF_STOCK = 'out_of_stock';
+ const EVENT_ON_BACKORDER = 'on_backorder';
+
+ const VALID_EVENT_TYPES = array(
+ self::EVENT_LOW_STOCK,
+ self::EVENT_OUT_OF_STOCK,
+ self::EVENT_ON_BACKORDER,
+ );
+
+ const ICON = 'https://s.wp.com/wp-content/mu-plugins/notes/images/tos-warning-note-icon.png';
+
+ /**
+ * The stock event that triggered this notification.
+ *
+ * @var string
+ */
+ private string $event_type;
+
+ /**
+ * Stock quantity captured at the moment the WC stock event fired.
+ *
+ * Captured at trigger time rather than read at dispatch time so the
+ * notification reflects the threshold-crossing moment, not whatever
+ * stock level the product happens to be at when the dispatcher (which
+ * runs in a separate process — internal REST endpoint or ActionScheduler
+ * safety net) eventually re-fetches the product. Avoids stale-cache reads
+ * and remains correct even if subsequent orders reduce stock further
+ * before dispatch.
+ *
+ * Only meaningful for the low_stock event today; null for the other two.
+ *
+ * @var int|null
+ */
+ private ?int $stock_quantity_at_trigger;
+
+ /**
+ * Creates a new StockNotification instance.
+ *
+ * @param int $resource_id The product ID.
+ * @param string $event_type One of the EVENT_* constants.
+ * @param int|null $stock_quantity_at_trigger Stock quantity captured when the WC stock event fired, or null if unknown.
+ *
+ * @throws InvalidArgumentException If the resource ID or event type is invalid.
+ *
+ * @since 10.9.0
+ */
+ public function __construct( int $resource_id, string $event_type = self::EVENT_LOW_STOCK, ?int $stock_quantity_at_trigger = null ) {
+ parent::__construct( $resource_id );
+
+ if ( ! in_array( $event_type, self::VALID_EVENT_TYPES, true ) ) {
+ // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ throw new InvalidArgumentException( sprintf( 'Invalid stock notification event type: %s', $event_type ) );
+ }
+
+ $this->event_type = $event_type;
+ $this->stock_quantity_at_trigger = $stock_quantity_at_trigger;
+ }
+
+ /**
+ * Restores extra state from a serialized notification array.
+ *
+ * Called by {@see Notification::from_array()} after construction to
+ * restore the event type that the default constructor cannot receive.
+ *
+ * Throws when `event_type` is present but unrecognized so the safety-net
+ * caller (which wraps reconstruction in a try/catch) drops the corrupt
+ * job rather than silently dispatching the wrong notification subtype.
+ * A missing `event_type` is allowed — the default set by the constructor
+ * survives, which preserves backward compatibility with any in-flight
+ * scheduled actions that pre-date this field.
+ *
+ * @param array $data The serialized notification data.
+ *
+ * @throws InvalidArgumentException If `event_type` is present but not a known value.
+ *
+ * @since 10.9.0
+ */
+ public function hydrate( array $data ): void {
+ if ( array_key_exists( 'event_type', $data ) ) {
+ $event_type = $data['event_type'];
+
+ if ( ! in_array( $event_type, self::VALID_EVENT_TYPES, true ) ) {
+ // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ throw new InvalidArgumentException( sprintf( 'Invalid stock notification event type during hydrate: %s', is_scalar( $event_type ) ? (string) $event_type : gettype( $event_type ) ) );
+ }
+
+ $this->event_type = $event_type;
+ }
+
+ if ( array_key_exists( 'stock_quantity_at_trigger', $data ) ) {
+ $stock = $data['stock_quantity_at_trigger'];
+ $this->stock_quantity_at_trigger = is_int( $stock ) ? $stock : null;
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function get_type(): string {
+ return self::TYPE;
+ }
+
+ /**
+ * Returns the stock event type.
+ *
+ * @return string
+ *
+ * @since 10.9.0
+ */
+ public function get_event_type(): string {
+ return $this->event_type;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Includes `event_type` so the same product can have distinct
+ * notifications for different stock events in-flight simultaneously.
+ */
+ public function get_identifier(): string {
+ return sprintf(
+ '%s_%s_%s_%s',
+ get_current_blog_id(),
+ $this->get_type(),
+ $this->event_type,
+ $this->get_resource_id()
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Extends the parent array with `event_type` and the trigger-time stock
+ * snapshot so both fields survive serialization through the safety-net
+ * scheduler and the internal-REST round-trip.
+ *
+ * @return array{type: string, resource_id: int, event_type: string, stock_quantity_at_trigger: int|null}
+ *
+ * @since 10.9.0
+ */
+ public function to_array(): array {
+ return array_merge(
+ parent::to_array(),
+ array(
+ 'event_type' => $this->event_type,
+ 'stock_quantity_at_trigger' => $this->stock_quantity_at_trigger,
+ )
+ );
+ }
+
+ /**
+ * Returns the WPCOM-ready payload for this notification.
+ *
+ * Returns null if the product no longer exists.
+ *
+ * @return array|null
+ *
+ * @since 10.9.0
+ */
+ public function to_payload(): ?array {
+ $product = WC()->call_function( 'wc_get_product', $this->get_resource_id() );
+
+ if ( ! $product || ! $product instanceof WC_Product ) {
+ return null;
+ }
+
+ $product_name = wp_strip_all_tags( $product->get_name() );
+ $site_title = wp_strip_all_tags( get_bloginfo( 'name' ) );
+
+ // For variations, `meta.product_id` is the parent product ID so the mobile app
+ // can always navigate to the product details screen. `resource_id` keeps the
+ // actual entity ID (variation or simple product) for identification and dedup.
+ $is_variation = $product->is_type( 'variation' );
+ $product_id = $is_variation ? $product->get_parent_id() : $product->get_id();
+
+ return array(
+ 'type' => $this->get_type(),
+ 'icon' => self::ICON,
+ 'timestamp' => gmdate( 'c' ),
+ 'resource_id' => $this->get_resource_id(),
+ 'title' => $this->build_title( $product_name ),
+ 'message' => $this->build_message( $product_name, $site_title, $product ),
+ 'meta' => array(
+ 'product_id' => $product_id,
+ 'event_type' => $this->event_type,
+ ),
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Extends the base enabled-toggle check with per-event sub-flag filtering.
+ *
+ * @param mixed $pref_value The user's stored preference value, or null.
+ * @return bool
+ *
+ * @since 10.9.0
+ */
+ public function should_send_to_user( $pref_value ): bool {
+ if ( ! parent::should_send_to_user( $pref_value ) ) {
+ return false;
+ }
+
+ if ( ! is_array( $pref_value ) || ! array_key_exists( $this->event_type, $pref_value ) ) {
+ return true;
+ }
+
+ return (bool) $pref_value[ $this->event_type ];
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @param string $key The meta key.
+ */
+ public function has_meta( string $key ): bool {
+ $product = WC()->call_function( 'wc_get_product', $this->get_resource_id() );
+ return $product instanceof WC_Product && $product->meta_exists( $key . '_' . $this->event_type );
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @param string $key The meta key.
+ */
+ public function write_meta( string $key ): void {
+ $product = WC()->call_function( 'wc_get_product', $this->get_resource_id() );
+
+ if ( $product instanceof WC_Product ) {
+ $product->update_meta_data( $key . '_' . $this->event_type, (string) time() );
+ $product->save_meta_data();
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @param string $key The meta key.
+ */
+ public function delete_meta( string $key ): void {
+ $product = WC()->call_function( 'wc_get_product', $this->get_resource_id() );
+
+ if ( $product instanceof WC_Product ) {
+ $product->delete_meta_data( $key . '_' . $this->event_type );
+ $product->save_meta_data();
+ }
+ }
+
+ /**
+ * Builds the title payload for the notification.
+ *
+ * @param string $product_name The sanitized product name.
+ * @return array{format: string, args: string[]}
+ */
+ private function build_title( string $product_name ): array {
+ switch ( $this->event_type ) {
+ case self::EVENT_OUT_OF_STOCK:
+ return array(
+ 'format' => 'Out of stock: %1$s',
+ 'args' => array( $product_name ),
+ );
+
+ case self::EVENT_ON_BACKORDER:
+ return array(
+ 'format' => 'Backordered: %1$s',
+ 'args' => array( $product_name ),
+ );
+
+ default:
+ return array(
+ 'format' => 'Low stock: %1$s',
+ 'args' => array( $product_name ),
+ );
+ }
+ }
+
+ /**
+ * Builds the message payload for the notification.
+ *
+ * @param string $product_name The sanitized product name.
+ * @param string $site_title The sanitized site title.
+ * @param WC_Product $product The product object (used as a fallback when no trigger-time stock was captured).
+ * @return array{format: string, args: string[]}
+ */
+ private function build_message( string $product_name, string $site_title, WC_Product $product ): array {
+ switch ( $this->event_type ) {
+ case self::EVENT_OUT_OF_STOCK:
+ return array(
+ 'format' => '%1$s is out of stock on %2$s',
+ 'args' => array( $product_name, $site_title ),
+ );
+
+ case self::EVENT_ON_BACKORDER:
+ return array(
+ 'format' => '%1$s has been backordered on %2$s',
+ 'args' => array( $product_name, $site_title ),
+ );
+
+ default:
+ $stock = null !== $this->stock_quantity_at_trigger
+ ? $this->stock_quantity_at_trigger
+ : $product->get_stock_quantity();
+
+ return array(
+ 'format' => '%1$s is running low (%2$s remaining) on %3$s',
+ 'args' => array(
+ $product_name,
+ (string) $stock,
+ $site_title,
+ ),
+ );
+ }//end switch
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index 752894d78ea..d7213b98702 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProce
use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificationTrigger;
use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewReviewNotificationTrigger;
+use Automattic\WooCommerce\Internal\PushNotifications\Triggers\StockNotificationTrigger;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
use WC_Logger;
@@ -82,6 +83,7 @@ class PushNotifications {
( new NotificationPreferencesRestController() )->register();
( new NewOrderNotificationTrigger() )->register();
( new NewReviewNotificationTrigger() )->register();
+ ( new StockNotificationTrigger() )->register();
wc_get_container()->get( NotificationProcessor::class )->register();
}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php
index d758214696e..4b30b15620b 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php
@@ -116,8 +116,11 @@ class NotificationPreferencesService {
$defaults[ $type ] = array( 'enabled' => true );
}
- $defaults['store_order']['min_amount'] = null;
- $defaults['store_review']['max_rating'] = null;
+ $defaults['store_order']['min_amount'] = null;
+ $defaults['store_review']['max_rating'] = null;
+ $defaults['store_stock']['low_stock'] = true;
+ $defaults['store_stock']['out_of_stock'] = true;
+ $defaults['store_stock']['on_backorder'] = true;
return $defaults;
}
@@ -188,6 +191,13 @@ class NotificationPreferencesService {
$sanitized[ $sub_key ] = ( $rating >= 1 && $rating <= 5 ) ? $rating : null;
continue;
}
+
+ if ( in_array( $sub_key, array( 'low_stock', 'out_of_stock', 'on_backorder' ), true ) ) {
+ $sanitized[ $sub_key ] = array_key_exists( $sub_key, $value )
+ ? (bool) $value[ $sub_key ]
+ : (bool) $sub_default;
+ continue;
+ }
}//end foreach
return $sanitized;
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
index 558183ffc39..f3c3093cb69 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
@@ -99,7 +99,7 @@ class NotificationProcessor {
* @since 10.7.0
*/
public function register(): void {
- add_action( self::SAFETY_NET_HOOK, array( $this, 'handle_safety_net' ), 10, 2 );
+ add_action( self::SAFETY_NET_HOOK, array( $this, 'handle_safety_net' ), 10, 3 );
}
/**
@@ -233,18 +233,23 @@ 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).
+ * Empty for notification types whose state is fully described by type + resource_id.
* @return void
*
* @since 10.7.0
*/
- public function handle_safety_net( string $type, int $resource_id ): void {
+ public function handle_safety_net( string $type, int $resource_id, array $extra = array() ): void {
try {
- $notification = Notification::from_array(
- array(
- 'type' => $type,
- 'resource_id' => $resource_id,
- )
- );
+ // Use the `+` array union operator (not array_merge) so the positional
+ // $type and $resource_id always win over any colliding keys in $extra.
+ // Defends against a malformed payload reconstructing the wrong target.
+ $data = array(
+ 'type' => $type,
+ 'resource_id' => $resource_id,
+ ) + $extra;
+
+ $notification = Notification::from_array( $data );
$this->process( $notification, true );
} catch ( Exception $e ) {
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
index 43a80cb4891..c9bcc3e0185 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
@@ -120,10 +120,16 @@ class PendingNotificationStore {
* @since 10.7.0
*/
private function schedule_safety_net( Notification $notification ): void {
- $args = array(
- 'type' => $notification->get_type(),
- 'resource_id' => $notification->get_resource_id(),
- );
+ $data = $notification->to_array();
+ $type = $data['type'];
+ $resource_id = $data['resource_id'];
+ unset( $data['type'], $data['resource_id'] );
+
+ // Pass `type` and `resource_id` positionally and bundle any subclass-specific
+ // extras (event_type, stock_quantity_at_trigger, etc.) into a single array
+ // argument so the safety-net callback signature stays stable as new
+ // notification subclasses add fields to to_array().
+ $args = array( $type, $resource_id, $data );
if ( as_has_scheduled_action( NotificationProcessor::SAFETY_NET_HOOK, $args, NotificationProcessor::ACTION_SCHEDULER_GROUP ) ) {
return;
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Triggers/StockNotificationTrigger.php b/plugins/woocommerce/src/Internal/PushNotifications/Triggers/StockNotificationTrigger.php
new file mode 100644
index 00000000000..173b7ecd310
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Triggers/StockNotificationTrigger.php
@@ -0,0 +1,102 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Triggers;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
+use WC_Product;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Listens for WooCommerce stock events and feeds stock notifications into
+ * the PendingNotificationStore.
+ *
+ * @since 10.9.0
+ */
+class StockNotificationTrigger {
+ /**
+ * Registers WordPress hooks for stock events.
+ *
+ * @return void
+ *
+ * @since 10.9.0
+ */
+ public function register(): void {
+ add_action( 'woocommerce_low_stock', array( $this, 'on_low_stock' ) );
+ add_action( 'woocommerce_no_stock', array( $this, 'on_no_stock' ) );
+ add_action( 'woocommerce_product_on_backorder', array( $this, 'on_backorder' ) );
+ }
+
+ /**
+ * Handles the woocommerce_low_stock hook.
+ *
+ * Captures the product's stock quantity at this moment so the dispatcher,
+ * which runs in a separate process and re-fetches the product, doesn't
+ * read a stale value if cache invalidation hasn't fully propagated.
+ *
+ * @param WC_Product $product The product whose stock is low.
+ * @return void
+ *
+ * @since 10.9.0
+ */
+ public function on_low_stock( WC_Product $product ): void {
+ $stock = $product->get_stock_quantity();
+ $this->add_notification(
+ $product->get_id(),
+ StockNotification::EVENT_LOW_STOCK,
+ null !== $stock ? (int) $stock : null
+ );
+ }
+
+ /**
+ * Handles the woocommerce_no_stock hook.
+ *
+ * @param WC_Product $product The product that is out of stock.
+ * @return void
+ *
+ * @since 10.9.0
+ */
+ public function on_no_stock( WC_Product $product ): void {
+ $this->add_notification( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+ }
+
+ /**
+ * Handles the woocommerce_product_on_backorder hook.
+ *
+ * @param array $args Backorder event data.
+ * @phpstan-param array{product: WC_Product, order_id: int, quantity: int|float} $args
+ * @return void
+ *
+ * @since 10.9.0
+ */
+ public function on_backorder( array $args ): void {
+ $product = $args['product'] ?? null;
+
+ if ( ! $product instanceof WC_Product ) {
+ return;
+ }
+
+ $this->add_notification( $product->get_id(), StockNotification::EVENT_ON_BACKORDER );
+ }
+
+ /**
+ * Creates a stock notification and adds it to the pending store.
+ *
+ * @param int $product_id The product ID.
+ * @param string $event_type The stock event type.
+ * @param int|null $stock_quantity_at_trigger Stock quantity at the moment WC fired the event, or null when not applicable.
+ * @return void
+ */
+ private function add_notification( int $product_id, string $event_type, ?int $stock_quantity_at_trigger = null ): void {
+ if ( $product_id <= 0 ) {
+ return;
+ }
+
+ wc_get_container()->get( PendingNotificationStore::class )->add(
+ new StockNotification( $product_id, $event_type, $stock_quantity_at_trigger )
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php
index 2da84125d81..785924f9f2c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php
@@ -464,6 +464,69 @@ class NotificationPreferencesRestControllerTest extends WC_REST_Unit_Test_Case {
$this->assertNull( $data['store_review']['max_rating'] );
}
+ /**
+ * @testdox GET should include store_stock with all sub-flags in the defaults.
+ */
+ public function test_get_preferences_includes_store_stock_with_sub_flags() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/preferences' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::OK, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'store_stock', $data );
+ $this->assertArrayHasKey( 'enabled', $data['store_stock'] );
+ $this->assertArrayHasKey( 'low_stock', $data['store_stock'] );
+ $this->assertArrayHasKey( 'out_of_stock', $data['store_stock'] );
+ $this->assertArrayHasKey( 'on_backorder', $data['store_stock'] );
+ }
+
+ /**
+ * @testdox POST should accept and persist store_stock sub-flag updates.
+ */
+ public function test_post_preferences_updates_stock_sub_flags() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+ $request->set_param(
+ 'store_stock',
+ array(
+ 'low_stock' => false,
+ 'on_backorder' => true,
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::OK, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertFalse( $data['store_stock']['low_stock'] );
+ $this->assertTrue( $data['store_stock']['on_backorder'] );
+ }
+
+ /**
+ * @testdox POST should reject non-boolean store_stock sub-fields via REST validation.
+ */
+ public function test_post_preferences_rejects_non_boolean_stock_sub_flag() {
+ wp_set_current_user( $this->user_id );
+ $this->mock_jetpack_connection_manager_is_connected( true );
+ $this->register_routes();
+
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+ $request->set_param( 'store_stock', array( 'low_stock' => 'not-a-boolean' ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::BAD_REQUEST, $response->get_status() );
+ }
+
/**
* @testdox Should not collide with PushTokenRestController on the WC REST namespaces filter.
*
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 4b3e91aa3c1..25e73e6c087 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
use InvalidArgumentException;
use WC_Unit_Test_Case;
@@ -60,6 +61,7 @@ class NotificationTest extends WC_Unit_Test_Case {
* @testdox from_array should create correct notification for $type type.
* @testWith ["store_order", "Automattic\\WooCommerce\\Internal\\PushNotifications\\Notifications\\NewOrderNotification"]
* ["store_review", "Automattic\\WooCommerce\\Internal\\PushNotifications\\Notifications\\NewReviewNotification"]
+ * ["store_stock", "Automattic\\WooCommerce\\Internal\\PushNotifications\\Notifications\\StockNotification"]
*
* @param string $type The notification type.
* @param string $expected_class The expected class name.
@@ -144,4 +146,36 @@ class NotificationTest extends WC_Unit_Test_Case {
$this->assertSame( $expected, $notification->should_send_to_user( $pref_value ) );
}
+
+ /**
+ * @testdox from_array should call hydrate() on classes that implement it.
+ */
+ public function test_from_array_hydrates_extra_fields(): void {
+ $notification = Notification::from_array(
+ array(
+ 'type' => 'store_stock',
+ 'resource_id' => 42,
+ 'event_type' => 'out_of_stock',
+ )
+ );
+
+ $this->assertInstanceOf( StockNotification::class, $notification );
+ $this->assertSame( 'out_of_stock', $notification->get_event_type() );
+ }
+
+ /**
+ * @testdox from_array should not break for types without hydrate() when extra data is present.
+ */
+ public function test_from_array_ignores_extra_fields_for_types_without_hydrate(): void {
+ $notification = Notification::from_array(
+ array(
+ 'type' => 'store_order',
+ 'resource_id' => 42,
+ 'extra' => 'should be ignored',
+ )
+ );
+
+ $this->assertInstanceOf( NewOrderNotification::class, $notification );
+ $this->assertSame( 42, $notification->get_resource_id() );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/StockNotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/StockNotificationTest.php
new file mode 100644
index 00000000000..62bfa258d04
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/StockNotificationTest.php
@@ -0,0 +1,414 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
+use InvalidArgumentException;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the StockNotification class.
+ */
+class StockNotificationTest extends WC_Unit_Test_Case {
+ /**
+ * @testdox Should return store_stock as the notification type.
+ */
+ public function test_type_is_store_stock(): void {
+ $notification = new StockNotification( 1 );
+
+ $this->assertSame( 'store_stock', $notification->get_type() );
+ }
+
+ /**
+ * @testdox Should return the product ID as the resource ID.
+ */
+ public function test_resource_id_matches_product_id(): void {
+ $notification = new StockNotification( 42 );
+
+ $this->assertSame( 42, $notification->get_resource_id() );
+ }
+
+ /**
+ * @testdox Should return the event type passed to the constructor.
+ * @dataProvider event_types_provider
+ *
+ * @param string $event_type The event type constant.
+ */
+ public function test_get_event_type_returns_constructor_value( string $event_type ): void {
+ $notification = new StockNotification( 1, $event_type );
+
+ $this->assertSame( $event_type, $notification->get_event_type() );
+ }
+
+ /**
+ * @testdox Should default event_type to low_stock when not provided.
+ */
+ public function test_constructor_defaults_event_type_to_low_stock(): void {
+ $notification = new StockNotification( 1 );
+
+ $this->assertSame( StockNotification::EVENT_LOW_STOCK, $notification->get_event_type() );
+ }
+
+ /**
+ * @testdox Should throw for an invalid event type.
+ */
+ public function test_constructor_throws_for_invalid_event_type(): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ new StockNotification( 1, 'invalid_event' );
+ }
+
+ /**
+ * @testdox Should return a payload with all required keys for an existing product.
+ */
+ public function test_to_payload_contains_required_keys(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 3,
+ )
+ );
+
+ $notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+ $payload = $notification->to_payload();
+
+ $this->assertArrayHasKey( 'type', $payload );
+ $this->assertArrayHasKey( 'timestamp', $payload );
+ $this->assertArrayHasKey( 'resource_id', $payload );
+ $this->assertArrayHasKey( 'title', $payload );
+ $this->assertArrayHasKey( 'format', $payload['title'] );
+ $this->assertArrayHasKey( 'args', $payload['title'] );
+ $this->assertArrayHasKey( 'message', $payload );
+ $this->assertArrayHasKey( 'format', $payload['message'] );
+ $this->assertArrayHasKey( 'args', $payload['message'] );
+ $this->assertArrayHasKey( 'icon', $payload );
+ $this->assertArrayHasKey( 'meta', $payload );
+ $this->assertArrayHasKey( 'product_id', $payload['meta'] );
+ $this->assertArrayHasKey( 'event_type', $payload['meta'] );
+ }
+
+ /**
+ * @testdox Should vary title format by event type.
+ * @dataProvider event_type_title_provider
+ *
+ * @param string $event_type The event type constant.
+ * @param string $expected_prefix The expected start of the title format.
+ */
+ public function test_to_payload_varies_title_by_event_type( string $event_type, string $expected_prefix ): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $notification = new StockNotification( $product->get_id(), $event_type );
+ $payload = $notification->to_payload();
+
+ $this->assertStringStartsWith( $expected_prefix, $payload['title']['format'] );
+ }
+
+ /**
+ * @testdox Should include stock quantity in the low_stock message args.
+ */
+ public function test_to_payload_includes_stock_quantity_for_low_stock(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 5,
+ )
+ );
+
+ $notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+ $payload = $notification->to_payload();
+
+ $this->assertSame( '5', $payload['message']['args'][1] );
+ }
+
+ /**
+ * @testdox Should prefer the trigger-time stock snapshot over the product's current stock so cache lag in the dispatcher process can't surface a stale value.
+ */
+ public function test_to_payload_uses_stock_quantity_at_trigger_for_low_stock(): void {
+ // Product currently shows stock=5 — simulating what a stale-cache re-fetch in the dispatcher process might return.
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 5,
+ )
+ );
+
+ // But at trigger time, the actual post-decrement stock was 1. That's what the merchant should see in the push.
+ $notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK, 1 );
+ $payload = $notification->to_payload();
+
+ $this->assertSame( '1', $payload['message']['args'][1] );
+ }
+
+ /**
+ * @testdox For a simple product, meta.product_id should equal the product ID.
+ */
+ public function test_to_payload_meta_product_id_for_simple_product(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 0,
+ )
+ );
+
+ $notification = new StockNotification( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+ $payload = $notification->to_payload();
+
+ $this->assertSame( $product->get_id(), $payload['meta']['product_id'] );
+ $this->assertSame( $product->get_id(), $payload['resource_id'] );
+ }
+
+ /**
+ * @testdox For a variation, meta.product_id should be the parent product ID so mobile can navigate to the product details screen, while resource_id keeps the variation ID for identification.
+ */
+ public function test_to_payload_meta_product_id_for_variation(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $variations = $parent->get_children();
+ $variation = wc_get_product( $variations[0] );
+ $variation->set_manage_stock( true );
+ $variation->set_stock_quantity( 0 );
+ $variation->save();
+
+ $notification = new StockNotification( $variation->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+ $payload = $notification->to_payload();
+
+ $this->assertSame( $parent->get_id(), $payload['meta']['product_id'] );
+ $this->assertSame( $variation->get_id(), $payload['resource_id'] );
+ $this->assertNotSame( $variation->get_id(), $payload['meta']['product_id'] );
+ }
+
+ /**
+ * @testdox Should return null when the product no longer exists.
+ */
+ public function test_to_payload_returns_null_for_deleted_product(): void {
+ $notification = new StockNotification( 999999 );
+
+ $this->assertNull( $notification->to_payload() );
+ }
+
+ /**
+ * @testdox to_array should include event_type alongside type and resource_id.
+ */
+ public function test_to_array_includes_event_type(): void {
+ $notification = new StockNotification( 42, StockNotification::EVENT_OUT_OF_STOCK );
+ $data = $notification->to_array();
+
+ $this->assertArrayHasKey( 'event_type', $data );
+ $this->assertSame( StockNotification::EVENT_OUT_OF_STOCK, $data['event_type'] );
+ $this->assertSame( 'store_stock', $data['type'] );
+ $this->assertSame( 42, $data['resource_id'] );
+ }
+
+ /**
+ * @testdox hydrate should restore event_type from serialized data.
+ */
+ public function test_hydrate_restores_event_type(): void {
+ $notification = new StockNotification( 1 );
+ $this->assertSame( StockNotification::EVENT_LOW_STOCK, $notification->get_event_type() );
+
+ $notification->hydrate( array( 'event_type' => StockNotification::EVENT_ON_BACKORDER ) );
+
+ $this->assertSame( StockNotification::EVENT_ON_BACKORDER, $notification->get_event_type() );
+ }
+
+ /**
+ * @testdox hydrate should throw on an unrecognized event_type so corrupt safety-net jobs are dropped instead of dispatching the wrong subtype.
+ */
+ public function test_hydrate_throws_on_invalid_event_type(): void {
+ $notification = new StockNotification( 1, StockNotification::EVENT_OUT_OF_STOCK );
+
+ $this->expectException( \InvalidArgumentException::class );
+
+ $notification->hydrate( array( 'event_type' => 'not-a-valid-event' ) );
+ }
+
+ /**
+ * @testdox hydrate should keep current event_type when key is missing.
+ */
+ public function test_hydrate_keeps_current_when_key_missing(): void {
+ $notification = new StockNotification( 1, StockNotification::EVENT_OUT_OF_STOCK );
+
+ $notification->hydrate( array() );
+
+ $this->assertSame( StockNotification::EVENT_OUT_OF_STOCK, $notification->get_event_type() );
+ }
+
+ /**
+ * @testdox to_array should include the trigger-time stock snapshot when present.
+ */
+ public function test_to_array_includes_stock_quantity_at_trigger(): void {
+ $notification = new StockNotification( 42, StockNotification::EVENT_LOW_STOCK, 1 );
+ $data = $notification->to_array();
+
+ $this->assertArrayHasKey( 'stock_quantity_at_trigger', $data );
+ $this->assertSame( 1, $data['stock_quantity_at_trigger'] );
+ }
+
+ /**
+ * @testdox hydrate should restore stock_quantity_at_trigger from serialized data so the safety-net path keeps the threshold-crossing value.
+ */
+ public function test_hydrate_restores_stock_quantity_at_trigger(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 5,
+ )
+ );
+
+ $notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+ $notification->hydrate(
+ array(
+ 'event_type' => StockNotification::EVENT_LOW_STOCK,
+ 'stock_quantity_at_trigger' => 1,
+ )
+ );
+
+ $payload = $notification->to_payload();
+ $this->assertSame( '1', $payload['message']['args'][1] );
+ }
+
+ /**
+ * @testdox get_identifier should differ for the same product with different event types.
+ */
+ public function test_get_identifier_differs_for_different_event_types(): void {
+ $low = new StockNotification( 42, StockNotification::EVENT_LOW_STOCK );
+ $out = new StockNotification( 42, StockNotification::EVENT_OUT_OF_STOCK );
+ $back = new StockNotification( 42, StockNotification::EVENT_ON_BACKORDER );
+
+ $this->assertNotSame( $low->get_identifier(), $out->get_identifier() );
+ $this->assertNotSame( $out->get_identifier(), $back->get_identifier() );
+ $this->assertNotSame( $low->get_identifier(), $back->get_identifier() );
+ }
+
+ /**
+ * @testdox should_send_to_user should return true when enabled and event sub-flag is true.
+ */
+ public function test_should_send_to_user_when_enabled_and_sub_flag_true(): void {
+ $notification = new StockNotification( 1, StockNotification::EVENT_LOW_STOCK );
+
+ $this->assertTrue(
+ $notification->should_send_to_user(
+ array(
+ 'enabled' => true,
+ 'low_stock' => true,
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox should_send_to_user should return false when enabled but event sub-flag is false.
+ */
+ public function test_should_not_send_to_user_when_sub_flag_false(): void {
+ $notification = new StockNotification( 1, StockNotification::EVENT_ON_BACKORDER );
+
+ $this->assertFalse(
+ $notification->should_send_to_user(
+ array(
+ 'enabled' => true,
+ 'on_backorder' => false,
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox should_send_to_user should return false when notification is disabled regardless of sub-flags.
+ */
+ public function test_should_not_send_to_user_when_disabled(): void {
+ $notification = new StockNotification( 1, StockNotification::EVENT_LOW_STOCK );
+
+ $this->assertFalse(
+ $notification->should_send_to_user(
+ array(
+ 'enabled' => false,
+ 'low_stock' => true,
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox should_send_to_user should default to true when event sub-flag key is missing.
+ */
+ public function test_should_send_to_user_when_sub_flag_missing(): void {
+ $notification = new StockNotification( 1, StockNotification::EVENT_LOW_STOCK );
+
+ $this->assertTrue(
+ $notification->should_send_to_user( array( 'enabled' => true ) )
+ );
+ }
+
+ /**
+ * @testdox should_send_to_user should return true when pref_value is null.
+ */
+ public function test_should_send_to_user_when_pref_null(): void {
+ $notification = new StockNotification( 1 );
+
+ $this->assertTrue( $notification->should_send_to_user( null ) );
+ }
+
+ /**
+ * @testdox Meta operations should use product post meta namespaced by event type.
+ */
+ public function test_meta_operations_use_product_meta(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+ $meta_key = '_test_meta';
+
+ $this->assertFalse( $notification->has_meta( $meta_key ) );
+
+ $notification->write_meta( $meta_key );
+ $this->assertTrue( $notification->has_meta( $meta_key ) );
+
+ $notification->delete_meta( $meta_key );
+ $this->assertFalse( $notification->has_meta( $meta_key ) );
+ }
+
+ /**
+ * @testdox Meta keys should be namespaced by event type to avoid collisions.
+ */
+ public function test_meta_key_is_namespaced_by_event_type(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $low = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+ $out = new StockNotification( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+ $meta_key = '_test_meta';
+
+ $low->write_meta( $meta_key );
+
+ $this->assertTrue( $low->has_meta( $meta_key ) );
+ $this->assertFalse( $out->has_meta( $meta_key ) );
+ }
+
+ /**
+ * Data provider for all valid event types.
+ *
+ * @return array<string, array{string}>
+ */
+ public function event_types_provider(): array {
+ return array(
+ 'low_stock' => array( StockNotification::EVENT_LOW_STOCK ),
+ 'out_of_stock' => array( StockNotification::EVENT_OUT_OF_STOCK ),
+ 'on_backorder' => array( StockNotification::EVENT_ON_BACKORDER ),
+ );
+ }
+
+ /**
+ * Data provider mapping event types to expected title prefixes.
+ *
+ * @return array<string, array{string, string}>
+ */
+ public function event_type_title_provider(): array {
+ return array(
+ 'low_stock' => array( StockNotification::EVENT_LOW_STOCK, 'Low stock:' ),
+ 'out_of_stock' => array( StockNotification::EVENT_OUT_OF_STOCK, 'Out of stock:' ),
+ 'on_backorder' => array( StockNotification::EVENT_ON_BACKORDER, 'Backordered:' ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php
index 743d0d77c96..1e44f279c79 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php
@@ -285,6 +285,7 @@ class NotificationPreferencesServiceTest extends WC_Unit_Test_Case {
$this->assertIsArray( $defaults );
$this->assertArrayHasKey( 'store_order', $defaults );
$this->assertArrayHasKey( 'store_review', $defaults );
+ $this->assertArrayHasKey( 'store_stock', $defaults );
foreach ( $defaults as $type => $shape ) {
$this->assertIsArray( $shape, "Default for {$type} should be an object/array." );
@@ -445,4 +446,98 @@ class NotificationPreferencesServiceTest extends WC_Unit_Test_Case {
array( 'store_review' => array( 'enabled' => false ) )
);
}
+
+ /**
+ * @testdox Should include stock sub-flag defaults for store_stock.
+ */
+ public function test_get_defaults_includes_stock_sub_flags(): void {
+ $defaults = $this->sut->get_defaults();
+
+ $this->assertArrayHasKey( 'low_stock', $defaults['store_stock'] );
+ $this->assertTrue( $defaults['store_stock']['low_stock'] );
+ $this->assertArrayHasKey( 'out_of_stock', $defaults['store_stock'] );
+ $this->assertTrue( $defaults['store_stock']['out_of_stock'] );
+ $this->assertArrayHasKey( 'on_backorder', $defaults['store_stock'] );
+ $this->assertTrue( $defaults['store_stock']['on_backorder'] );
+ }
+
+ /**
+ * @testdox Should coerce stock sub-flags to booleans.
+ */
+ public function test_sanitize_coerces_stock_sub_flags_to_bool(): void {
+ $this->data_store->method( 'read' )->willReturn( null );
+
+ $result = $this->sut->save_preferences(
+ $this->user_id,
+ array(
+ 'store_stock' => array(
+ 'low_stock' => 1,
+ 'out_of_stock' => 0,
+ ),
+ )
+ );
+
+ $this->assertTrue( $result['store_stock']['low_stock'] );
+ $this->assertFalse( $result['store_stock']['out_of_stock'] );
+ }
+
+ /**
+ * @testdox Should drop unknown sub-fields within store_stock.
+ */
+ public function test_save_preferences_drops_unknown_stock_sub_fields(): void {
+ $this->data_store->method( 'read' )->willReturn( null );
+
+ $result = $this->sut->save_preferences(
+ $this->user_id,
+ array(
+ 'store_stock' => array(
+ 'enabled' => true,
+ 'low_stock' => true,
+ 'unknown_event' => true,
+ ),
+ )
+ );
+
+ $this->assertArrayHasKey( 'store_stock', $result );
+ $this->assertArrayNotHasKey( 'unknown_event', $result['store_stock'] );
+ }
+
+ /**
+ * @testdox Should deep-merge partial store_stock updates preserving unrelated sub-fields.
+ */
+ public function test_save_preferences_deep_merges_stock_sub_flags(): void {
+ $this->data_store->method( 'read' )->willReturn(
+ array(
+ 'schema_version' => NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION,
+ 'preferences' => array(
+ 'store_stock' => array(
+ 'enabled' => true,
+ 'low_stock' => true,
+ 'out_of_stock' => true,
+ 'on_backorder' => false,
+ ),
+ ),
+ )
+ );
+
+ $this->data_store
+ ->expects( $this->once() )
+ ->method( 'write' )
+ ->with(
+ $this->anything(),
+ $this->callback(
+ function ( $envelope ) {
+ $prefs = $envelope['preferences']['store_stock'];
+ return false === $prefs['low_stock']
+ && true === $prefs['out_of_stock']
+ && false === $prefs['on_backorder'];
+ }
+ )
+ );
+
+ $this->sut->save_preferences(
+ $this->user_id,
+ array( 'store_stock' => array( 'low_stock' => false ) )
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
index 7ba071671ed..c8871bb5957 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+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;
@@ -80,6 +81,12 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
'min_amount' => null,
),
'store_review' => array( 'enabled' => true ),
+ 'store_stock' => array(
+ 'enabled' => true,
+ 'low_stock' => true,
+ 'out_of_stock' => true,
+ 'on_backorder' => false,
+ ),
)
);
@@ -603,4 +610,189 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
$this->assertTrue( $result );
$this->assertNotEmpty( get_comment_meta( $comment_id, NotificationProcessor::SENT_META_KEY, true ) );
}
+
+ /**
+ * @testdox Should handle safety net callback with a stock notification event_type.
+ */
+ public function test_handle_safety_net_with_stock_event_type(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 3,
+ )
+ );
+
+ $this->dispatcher->expects( $this->once() )->method( 'dispatch' )->willReturn(
+ array(
+ 'success' => true,
+ 'retry_after' => null,
+ )
+ );
+
+ $this->sut->handle_safety_net(
+ 'store_stock',
+ $product->get_id(),
+ array( 'event_type' => 'low_stock' )
+ );
+
+ $refreshed = wc_get_product( $product->get_id() );
+ $this->assertNotEmpty(
+ $refreshed->get_meta( NotificationProcessor::SENT_META_KEY . '_low_stock' )
+ );
+ }
+
+ /**
+ * @testdox Safety net should default the extras array so notification types without subclass-specific state still work.
+ */
+ public function test_handle_safety_net_omits_extras_for_simple_types(): void {
+ $this->dispatcher->expects( $this->once() )->method( 'dispatch' )->willReturn(
+ array(
+ 'success' => true,
+ 'retry_after' => null,
+ )
+ );
+
+ $this->sut->handle_safety_net( 'store_order', $this->order_id );
+
+ $order = wc_get_order( $this->order_id );
+ $this->assertNotEmpty( $order->get_meta( NotificationProcessor::SENT_META_KEY ) );
+ }
+
+ /**
+ * @testdox Safety net should ignore type/resource_id keys smuggled into the extras array so the positional params remain authoritative.
+ */
+ public function test_handle_safety_net_extras_cannot_override_positional_params(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 3,
+ )
+ );
+
+ $captured_notification = null;
+ $this->dispatcher
+ ->expects( $this->once() )
+ ->method( 'dispatch' )
+ ->with(
+ $this->callback(
+ function ( $notification ) use ( &$captured_notification ) {
+ $captured_notification = $notification;
+ return true;
+ }
+ )
+ )
+ ->willReturn(
+ array(
+ 'success' => true,
+ 'retry_after' => null,
+ )
+ );
+
+ // `type` and `resource_id` keys inside the extras array are smuggled values
+ // that must be ignored — the positional params should remain authoritative.
+ $this->sut->handle_safety_net(
+ 'store_stock',
+ $product->get_id(),
+ array(
+ 'event_type' => 'low_stock',
+ 'type' => 'store_order',
+ 'resource_id' => 999999,
+ )
+ );
+
+ $this->assertNotNull( $captured_notification );
+ $this->assertSame( 'store_stock', $captured_notification->get_type() );
+ $this->assertSame( $product->get_id(), $captured_notification->get_resource_id() );
+ }
+
+ /**
+ * @testdox Safety net should propagate the stock quantity captured at trigger time when reconstructing the notification.
+ */
+ public function test_handle_safety_net_with_stock_quantity_at_trigger(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ // Product currently shows stock=5 (the dispatcher might re-fetch and see this).
+ 'stock_quantity' => 5,
+ )
+ );
+
+ $captured_payload = null;
+ $this->dispatcher
+ ->expects( $this->once() )
+ ->method( 'dispatch' )
+ ->with(
+ $this->callback(
+ function ( $notification ) use ( &$captured_payload ) {
+ $captured_payload = $notification->to_payload();
+ return true;
+ }
+ )
+ )
+ ->willReturn(
+ array(
+ 'success' => true,
+ 'retry_after' => null,
+ )
+ );
+
+ // Trigger-time stock was 1 (post-decrement), even though current product stock is 5.
+ $this->sut->handle_safety_net(
+ 'store_stock',
+ $product->get_id(),
+ array(
+ 'event_type' => 'low_stock',
+ 'stock_quantity_at_trigger' => 1,
+ )
+ );
+
+ $this->assertNotNull( $captured_payload );
+ $this->assertSame( '1', $captured_payload['message']['args'][1] );
+ }
+
+ /**
+ * @testdox Should skip dispatch for stock notification when the matching sub-flag is disabled.
+ */
+ public function test_process_skips_dispatch_when_stock_sub_flag_disabled(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 3,
+ )
+ );
+
+ $preferences_service = $this->createMock( NotificationPreferencesService::class );
+ $preferences_service->method( 'get_preferences' )->willReturn(
+ array(
+ 'store_order' => array(
+ 'enabled' => true,
+ 'min_amount' => null,
+ ),
+ 'store_review' => array(
+ 'enabled' => true,
+ 'max_rating' => null,
+ ),
+ 'store_stock' => array(
+ 'enabled' => true,
+ 'low_stock' => false,
+ 'out_of_stock' => true,
+ 'on_backorder' => false,
+ ),
+ )
+ );
+
+ $this->dispatcher->expects( $this->never() )->method( 'dispatch' );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $this->data_store, $preferences_service );
+
+ $notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+ $result = $sut->process( $notification );
+
+ $this->assertTrue( $result );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
index 26a7b522d19..18f3028e7c9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Services;
use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
use WC_Unit_Test_Case;
@@ -203,4 +204,38 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
->onlyMethods( array( 'to_payload', 'has_meta', 'write_meta' ) )
->getMock();
}
+
+ /**
+ * Creates a mock StockNotification that avoids database calls.
+ *
+ * @param int $resource_id The resource ID.
+ * @param string $event_type The stock event type.
+ * @return StockNotification
+ */
+ private function create_stock_mock( int $resource_id, string $event_type ): StockNotification {
+ return $this->getMockBuilder( StockNotification::class )
+ ->setConstructorArgs( array( $resource_id, $event_type ) )
+ ->onlyMethods( array( 'to_payload', 'has_meta', 'write_meta' ) )
+ ->getMock();
+ }
+
+ /**
+ * @testdox Should store different stock event types for the same product separately.
+ */
+ public function test_add_allows_different_stock_event_types_for_same_product(): void {
+ $this->store->add( $this->create_stock_mock( 42, StockNotification::EVENT_LOW_STOCK ) );
+ $this->store->add( $this->create_stock_mock( 42, StockNotification::EVENT_OUT_OF_STOCK ) );
+
+ $this->assertSame( 2, $this->store->count() );
+ }
+
+ /**
+ * @testdox Should deduplicate the same stock event type for the same product.
+ */
+ public function test_add_deduplicates_same_stock_event_type_and_product(): void {
+ $this->store->add( $this->create_stock_mock( 42, StockNotification::EVENT_LOW_STOCK ) );
+ $this->store->add( $this->create_stock_mock( 42, StockNotification::EVENT_LOW_STOCK ) );
+
+ $this->assertSame( 1, $this->store->count() );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/StockNotificationTriggerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/StockNotificationTriggerTest.php
new file mode 100644
index 00000000000..765b3e0ade5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/StockNotificationTriggerTest.php
@@ -0,0 +1,190 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Triggers;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Triggers\StockNotificationTrigger;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the StockNotificationTrigger class.
+ */
+class StockNotificationTriggerTest extends WC_Unit_Test_Case {
+ /**
+ * An instance of StockNotificationTrigger.
+ *
+ * @var StockNotificationTrigger
+ */
+ private $trigger;
+
+ /**
+ * The notification store used by the trigger.
+ *
+ * @var PendingNotificationStore
+ */
+ private $store;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $dispatcher = $this->createMock( InternalNotificationDispatcher::class );
+ $this->store = new PendingNotificationStore();
+
+ $this->store->init( $dispatcher );
+ $this->store->register();
+
+ wc_get_container()->replace( PendingNotificationStore::class, $this->store );
+ wc_get_container()->reset_all_resolved();
+
+ $this->trigger = new StockNotificationTrigger();
+ $this->trigger->register();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ remove_action( 'woocommerce_low_stock', array( $this->trigger, 'on_low_stock' ) );
+ remove_action( 'woocommerce_no_stock', array( $this->trigger, 'on_no_stock' ) );
+ remove_action( 'woocommerce_product_on_backorder', array( $this->trigger, 'on_backorder' ) );
+ remove_action( 'shutdown', array( $this->store, 'dispatch_all' ) );
+
+ $this->reset_container_replacements();
+ wc_get_container()->reset_all_resolved();
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Should add a notification when the low stock hook fires and capture the stock quantity at trigger time.
+ */
+ public function test_low_stock_hook_adds_notification(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 2,
+ )
+ );
+
+ $this->trigger->on_low_stock( $product );
+
+ $this->assertSame( 1, $this->store->count() );
+
+ $notifications = $this->store->get_all();
+ $this->assertInstanceOf( StockNotification::class, $notifications[0] );
+ $this->assertSame( StockNotification::EVENT_LOW_STOCK, $notifications[0]->get_event_type() );
+
+ // The stock snapshot is captured at trigger time so the dispatcher (which runs in a separate process)
+ // doesn't read a stale value if cache invalidation hasn't propagated.
+ $this->assertSame( 2, $notifications[0]->to_array()['stock_quantity_at_trigger'] );
+ }
+
+ /**
+ * @testdox Should add a notification when the no stock hook fires.
+ */
+ public function test_no_stock_hook_adds_notification(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 0,
+ )
+ );
+
+ $this->trigger->on_no_stock( $product );
+
+ $this->assertSame( 1, $this->store->count() );
+
+ $notifications = $this->store->get_all();
+ $this->assertSame( StockNotification::EVENT_OUT_OF_STOCK, $notifications[0]->get_event_type() );
+ }
+
+ /**
+ * @testdox Should add a notification when the backorder hook fires with a valid product.
+ */
+ public function test_backorder_hook_adds_notification(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $this->trigger->on_backorder(
+ array(
+ 'product' => $product,
+ 'order_id' => 1,
+ 'quantity' => 3,
+ )
+ );
+
+ $this->assertSame( 1, $this->store->count() );
+
+ $notifications = $this->store->get_all();
+ $this->assertSame( StockNotification::EVENT_ON_BACKORDER, $notifications[0]->get_event_type() );
+ }
+
+ /**
+ * @testdox Should ignore the backorder hook when the product is not a WC_Product.
+ */
+ public function test_backorder_hook_ignores_invalid_product(): void {
+ $this->trigger->on_backorder(
+ array(
+ 'product' => 'not-a-product',
+ 'order_id' => 1,
+ 'quantity' => 3,
+ )
+ );
+
+ $this->assertSame( 0, $this->store->count() );
+ }
+
+ /**
+ * @testdox Should ignore the backorder hook when the product key is missing.
+ */
+ public function test_backorder_hook_ignores_missing_product(): void {
+ $this->trigger->on_backorder( array( 'order_id' => 1 ) );
+
+ $this->assertSame( 0, $this->store->count() );
+ }
+
+ /**
+ * @testdox Should store different event types for the same product separately.
+ */
+ public function test_different_events_same_product_stored_separately(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 0,
+ )
+ );
+
+ $this->trigger->on_low_stock( $product );
+ $this->trigger->on_no_stock( $product );
+
+ $this->assertSame( 2, $this->store->count() );
+ }
+
+ /**
+ * @testdox Should deduplicate the same event type for the same product.
+ */
+ public function test_same_event_same_product_deduplicated(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 2,
+ )
+ );
+
+ $this->trigger->on_low_stock( $product );
+ $this->trigger->on_low_stock( $product );
+
+ $this->assertSame( 1, $this->store->count() );
+ }
+}