Commit 25ca8e65693 for woocommerce
commit 25ca8e656937378844d3899f8f1c6d0a9307921d
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date: Wed Sep 23 17:38:29 2026 +0100
Record push notification time when the event happens, not when the store sends it (#67576)
* Record push notification time when the event happens, not when the store sends it
Push notification payloads carry a `timestamp` field that delivery
monitoring uses to measure how quickly a store dispatches notifications.
It was filled in at the moment of sending, so a delayed send moved the
timestamp with it and monitoring read healthy while notifications arrived
minutes late.
The time is now captured when the store event fires and carried through
unchanged, whichever path performs the send.
- Capture the trigger time when the notification is added to
`PendingNotificationStore`, and persist it to the resource on shutdown.
Writing at trigger time would land inside `WC_Order_Data_Store::create()`,
where an HPOS meta save can escalate into a full `$order->save()` on an
order whose line items have not been written yet. WordPress fires
`shutdown` on fatals too, so only requests killed outright lose the value,
and those fall back to the current time.
- Skip the write when the notification is already marked sent, so a repeat
`woocommerce_low_stock` event does not pay for a write and a product meta
cache flush that nothing will read.
- Read the recorded time back when building the payload, from the resource
`to_payload()` has already loaded.
One supporting change. `Notification::reset_processing_meta()` clears the
claimed and triggered meta on every terminal path, including the retry
handler's give-up branches. The claimed marker previously survived anything
except a successful send, and `process()` returns early when it is present,
so a resource that kept one could stop sending that notification type. The
trigger time is cleared at the same point: it has no use once the payload is
built, and would otherwise be a permanent row per notifiable order and
review. The sent marker is kept, since it is the idempotency guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Keep the first trigger time and isolate recording failures.
- Skip recording when either the sent or the triggered marker is already present, so a resource that triggers again while its notification is still in flight keeps the time of the first trigger. Core fires the stock hooks on every reduction past the threshold rather than only on the crossing, which would otherwise drag the recorded time forward and understate the measured delivery age.
- Check the sent marker as well as the triggered one, because a successful send clears the trigger time. Without it a repeat trigger would recreate a value that nothing clears a second time.
- Catch failures per notification in record_trigger_times() and log them. Writing the trigger time saves the resource, so a third-party handler on the save hooks could previously stop the dispatch that follows and push every notification in the request onto the safety net.
* Clear the bookkeeping meta when a retry cannot be scheduled
as_schedule_single_action() returns 0 when Action Scheduler is not
initialised or the store rejects the action. That path left the claimed
and triggered markers on the resource, and NotificationProcessor::process()
returns early while a claimed marker is present, so the resource would
stop sending that notification type altogether.
The other two give-up branches already reset the meta. This makes the
third one behave the same way and logs the failure, which was otherwise
silent.
* Pin the payload timestamp tests to a fixed trigger time
The tests built the expected value with the same gmdate( 'c', ... ) call
the payload uses, so the assertion held whatever that call produced and
the formatting was never checked. A fixed input and a literal expectation
mean a change to the format fails the test.
The two fallback tests keep a relative time, because they assert that an
absent trigger time falls back to the current one.
* Call the claimed, sent and triggered meta the delivery state
"Bookkeeping" reads as audit data that could be dropped. These keys decide
whether a notification sends at all, so name them for what they do.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
index 6ffc45e7faf..19c03351eba 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\PushNotifications\Notifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use WC_Order;
defined( 'ABSPATH' ) || exit;
@@ -50,7 +51,7 @@ class NewOrderNotification extends Notification {
return array(
'type' => $this->get_type(),
// This represents the time the notification was triggered, so we can monitor age of notification at delivery.
- 'timestamp' => gmdate( 'c' ),
+ 'timestamp' => $this->format_triggered_timestamp( (string) $order->get_meta( NotificationProcessor::TRIGGERED_META_KEY ) ),
'resource_id' => $this->get_resource_id(),
'title' => array(
/**
@@ -131,11 +132,23 @@ class NewOrderNotification extends Notification {
*
* @param string $key The meta key.
*/
- public function write_meta( string $key ): void {
+ public function read_meta( string $key ): string {
+ $order = WC()->call_function( 'wc_get_order', $this->get_resource_id() );
+
+ return $order instanceof WC_Order ? (string) $order->get_meta( $key ) : '';
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @param string $key The meta key.
+ * @param int|null $timestamp Unix timestamp to record. Defaults to the current time.
+ */
+ public function write_meta( string $key, ?int $timestamp = null ): void {
$order = WC()->call_function( 'wc_get_order', $this->get_resource_id() );
if ( $order instanceof WC_Order ) {
- $order->update_meta_data( $key, (string) time() );
+ $order->update_meta_data( $key, (string) ( $timestamp ?? time() ) );
$order->save_meta_data();
}
}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
index 2914b7ccdbe..96e949de328 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
@@ -80,7 +80,7 @@ class NewReviewNotification extends Notification {
return array(
'type' => $this->get_type(),
// This represents the time the notification was triggered, so we can monitor age of notification at delivery.
- 'timestamp' => gmdate( 'c' ),
+ 'timestamp' => $this->get_triggered_timestamp(),
'resource_id' => $this->get_resource_id(),
'title' => array(
/**
@@ -120,8 +120,18 @@ class NewReviewNotification extends Notification {
*
* @param string $key The meta key.
*/
- public function write_meta( string $key ): void {
- WC()->call_function( 'update_comment_meta', $this->get_resource_id(), $key, (string) time() );
+ public function read_meta( string $key ): string {
+ return (string) WC()->call_function( 'get_comment_meta', $this->get_resource_id(), $key, true );
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @param string $key The meta key.
+ * @param int|null $timestamp Unix timestamp to record. Defaults to the current time.
+ */
+ public function write_meta( string $key, ?int $timestamp = null ): void {
+ WC()->call_function( 'update_comment_meta', $this->get_resource_id(), $key, (string) ( $timestamp ?? time() ) );
}
/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
index d898a4a75c4..ed1b857458a 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\PushNotifications\Notifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use InvalidArgumentException;
defined( 'ABSPATH' ) || exit;
@@ -36,6 +37,18 @@ abstract class Notification {
*/
private int $resource_id;
+ /**
+ * Unix timestamp of the moment the store event fired, when known.
+ *
+ * Set by {@see PendingNotificationStore::add()} at trigger time and read by
+ * {@see PendingNotificationStore::dispatch_all()}, which persists it on
+ * shutdown. Null on any instance rebuilt in a later request (loopback,
+ * safety net, retry); those read the persisted value instead.
+ *
+ * @var int|null
+ */
+ private ?int $triggered_at = null;
+
/**
* Creates a new Notification instance.
*
@@ -88,12 +101,26 @@ abstract class Notification {
/**
* Writes a meta key with a timestamp to this notification's resource.
*
- * @param string $key The meta key.
+ * @param string $key The meta key.
+ * @param int|null $timestamp Unix timestamp to record. Defaults to the current time.
* @return void
*
* @since 10.7.0
*/
- abstract public function write_meta( string $key ): void;
+ abstract public function write_meta( string $key, ?int $timestamp = null ): void;
+
+ /**
+ * Reads a meta value from this notification's resource.
+ *
+ * Returns an empty string when the key is absent or the resource no longer
+ * exists.
+ *
+ * @param string $key The meta key.
+ * @return string
+ *
+ * @since 11.2.0
+ */
+ abstract public function read_meta( string $key ): string;
/**
* Deletes a meta key from this notification's resource.
@@ -105,6 +132,55 @@ abstract class Notification {
*/
abstract public function delete_meta( string $key ): void;
+ /**
+ * Clears the in-progress delivery state from this notification's resource.
+ *
+ * Call this on every terminal path (success, no recipients, and each of the
+ * retry handler's give-up branches) so a notification that has finished
+ * leaves nothing behind. The sent marker is deliberately not cleared: it is
+ * the idempotency guard that stops {@see NotificationProcessor::process()}
+ * sending the same notification twice, and only
+ * {@see StockNotificationRecoveryHandler} clears it, to re-arm a stock
+ * notification after a restock.
+ *
+ * Leaving the claimed marker behind is not only a storage cost. A stock
+ * notification that exhausted its retries would keep it forever, and
+ * {@see NotificationProcessor::process()} returns early when it is present,
+ * so that product could never send that notification again.
+ *
+ * @return void
+ *
+ * @since 11.2.0
+ */
+ public function reset_processing_meta(): void {
+ $this->delete_meta( NotificationProcessor::CLAIMED_META_KEY );
+ $this->delete_meta( NotificationProcessor::TRIGGERED_META_KEY );
+ }
+
+ /**
+ * Records the moment the store event fired.
+ *
+ * @param int $timestamp Unix timestamp.
+ * @return void
+ *
+ * @since 11.2.0
+ */
+ public function set_triggered_at( int $timestamp ): void {
+ $this->triggered_at = $timestamp;
+ }
+
+ /**
+ * Returns the trigger time captured in this request, or null when this
+ * instance was rebuilt in a later one.
+ *
+ * @return int|null
+ *
+ * @since 11.2.0
+ */
+ public function get_triggered_at(): ?int {
+ return $this->triggered_at;
+ }
+
/**
* Returns the notification data as an array.
*
@@ -122,7 +198,11 @@ abstract class Notification {
/**
* Reconstructs a Notification subclass from a serialized array.
*
- * @param array{type: string, resource_id: int} $data The notification data.
+ * `type` and `resource_id` are required. Any further keys are passed to the
+ * subclass's `hydrate()` method, which decides what it recognises; see
+ * {@see StockNotification::hydrate()}.
+ *
+ * @param array<string, mixed> $data The notification data.
* @return self
*
* @throws InvalidArgumentException If the type is unknown.
@@ -149,6 +229,47 @@ abstract class Notification {
return $instance;
}
+ /**
+ * Returns the ISO 8601 timestamp of the moment this notification was
+ * triggered, for the payload's `timestamp` field.
+ *
+ * Reads the trigger time recorded on the resource when the store event
+ * fired (see {@see NotificationProcessor::TRIGGERED_META_KEY}). Previously
+ * the payload was stamped with the current time at send, so any delay
+ * between the event and the send (safety net, retries) was invisible to
+ * delivery-age monitoring. Falls back to the current time when no trigger
+ * time was recorded (e.g. notifications already in flight when this shipped).
+ *
+ * @return string
+ *
+ * @since 11.2.0
+ */
+ public function get_triggered_timestamp(): string {
+ return $this->format_triggered_timestamp( $this->read_meta( NotificationProcessor::TRIGGERED_META_KEY ) );
+ }
+
+ /**
+ * Formats a recorded trigger time for the payload's `timestamp` field.
+ *
+ * Subclasses call this directly from `to_payload()` when they already hold
+ * the loaded resource, which avoids {@see self::get_triggered_timestamp()}
+ * fetching it a second time.
+ *
+ * @param string $recorded_at The raw meta value, or an empty string when absent.
+ * @return string
+ *
+ * @since 11.2.0
+ */
+ protected function format_triggered_timestamp( string $recorded_at ): string {
+ $triggered_at = $this->triggered_at ?? (int) $recorded_at;
+
+ if ( $triggered_at <= 0 ) {
+ $triggered_at = time();
+ }
+
+ return gmdate( 'c', $triggered_at );
+ }
+
/**
* Returns a unique identifier for this notification, used for
* deduplication.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php
index 6335b2d1e42..881a5643431 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\PushNotifications\Notifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use InvalidArgumentException;
use WC_Product;
@@ -226,7 +227,7 @@ class StockNotification extends Notification {
return array(
'type' => $this->get_type(),
- 'timestamp' => gmdate( 'c' ),
+ 'timestamp' => $this->format_triggered_timestamp( (string) $product->get_meta( $this->meta_key( NotificationProcessor::TRIGGERED_META_KEY ) ) ),
'resource_id' => $this->get_resource_id(),
'title' => $this->build_title( $product_name ),
'message' => $this->build_message( $product_name, $site_title, $product ),
@@ -266,7 +267,7 @@ class StockNotification extends Notification {
*/
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 );
+ return $product instanceof WC_Product && $product->meta_exists( $this->meta_key( $key ) );
}
/**
@@ -274,11 +275,23 @@ class StockNotification extends Notification {
*
* @param string $key The meta key.
*/
- public function write_meta( string $key ): void {
+ public function read_meta( string $key ): string {
+ $product = WC()->call_function( 'wc_get_product', $this->get_resource_id() );
+
+ return $product instanceof WC_Product ? (string) $product->get_meta( $this->meta_key( $key ) ) : '';
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @param string $key The meta key.
+ * @param int|null $timestamp Unix timestamp to record. Defaults to the current time.
+ */
+ public function write_meta( string $key, ?int $timestamp = null ): 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->update_meta_data( $this->meta_key( $key ), (string) ( $timestamp ?? time() ) );
$product->save_meta_data();
}
}
@@ -292,11 +305,27 @@ class StockNotification extends Notification {
$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->delete_meta_data( $this->meta_key( $key ) );
$product->save_meta_data();
}
}
+ /**
+ * Scopes a delivery state meta key to this notification's stock event.
+ *
+ * The same product can have low_stock, out_of_stock and on_backorder
+ * notifications in flight at once, so each event type needs its own
+ * claimed, sent and triggered markers.
+ *
+ * @param string $key The unscoped meta key.
+ * @return string
+ *
+ * @since 11.2.0
+ */
+ private function meta_key( string $key ): string {
+ return $key . '_' . $this->event_type;
+ }
+
/**
* Builds the title payload for the notification.
*
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
index 41c8b41e3f6..acf1d42bfe6 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
@@ -39,6 +39,20 @@ class NotificationProcessor {
*/
const SAFETY_NET_HOOK = 'wc_push_notification_safety_net';
+ /**
+ * Meta key recording when the notification was triggered.
+ *
+ * Captured by {@see PendingNotificationStore::add()} at the moment the store
+ * event fires, persisted on shutdown, and read back when the payload is
+ * built, which can happen much later (ActionScheduler safety net, retries).
+ * Persisting it on the resource means every send path reports the true
+ * event time. Cleared alongside the claimed marker by
+ * {@see Notification::reset_processing_meta()} once the notification is finished.
+ *
+ * @since 11.2.0
+ */
+ const TRIGGERED_META_KEY = '_wc_push_notification_triggered';
+
/**
* Meta key written before the WPCOM send attempt.
*/
@@ -170,6 +184,7 @@ class NotificationProcessor {
*/
if ( empty( $tokens ) ) {
$notification->write_meta( self::SENT_META_KEY );
+ $notification->reset_processing_meta();
$this->cancel_safety_net( $notification );
return true;
}
@@ -178,7 +193,7 @@ class NotificationProcessor {
if ( ! empty( $result['success'] ) ) {
$notification->write_meta( self::SENT_META_KEY );
- $notification->delete_meta( self::CLAIMED_META_KEY );
+ $notification->reset_processing_meta();
$this->cancel_safety_net( $notification );
return true;
}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php
index f5374926fef..2413477ba09 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php
@@ -72,6 +72,12 @@ class NotificationRetryHandler {
* If the maximum number of retries has been reached, logs a permanent
* failure instead of scheduling another attempt.
*
+ * Every path that ends without a scheduled retry calls
+ * {@see Notification::reset_processing_meta()}, including a scheduling call
+ * that returns 0. Action Scheduler returns 0 when it is not initialised or
+ * when the store rejects the action, and nothing else would then clear the
+ * claimed marker that {@see NotificationProcessor::process()} checks.
+ *
* @param Notification $notification The notification that failed.
* @param int|null $retry_after Optional Retry-After value from WPCOM (seconds).
* @param int $current_attempt The attempt number that just failed (0-based).
@@ -92,6 +98,7 @@ class NotificationRetryHandler {
),
array( 'source' => PushNotifications::FEATURE_NAME )
);
+ $notification->reset_processing_meta();
return;
}
@@ -108,10 +115,11 @@ class NotificationRetryHandler {
),
array( 'source' => PushNotifications::FEATURE_NAME )
);
+ $notification->reset_processing_meta();
return;
}
- as_schedule_single_action(
+ $action_id = as_schedule_single_action(
time() + $delay,
self::RETRY_HOOK,
array(
@@ -122,6 +130,19 @@ class NotificationRetryHandler {
NotificationProcessor::ACTION_SCHEDULER_GROUP,
true
);
+
+ if ( ! $action_id ) {
+ wc_get_logger()->error(
+ sprintf(
+ 'Push notification retry could not be scheduled (type=%s, resource_id=%d, attempt=%d).',
+ $notification->get_type(),
+ $notification->get_resource_id(),
+ $next_attempt
+ ),
+ array( 'source' => PushNotifications::FEATURE_NAME )
+ );
+ $notification->reset_processing_meta();
+ }
}
/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
index a988cf7f7d7..aaf0b4521f0 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
@@ -9,8 +9,10 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
+use Throwable;
/**
* Store that collects notifications during a request and dispatches them all on
@@ -117,6 +119,12 @@ class PendingNotificationStore {
$this->pending[ $key ] = $notification;
+ // Capture the trigger time now, but persist it on shutdown (see
+ // dispatch_all()). Writing here would land inside the order-creation
+ // call stack, where an HPOS meta save can escalate into a full
+ // $order->save() on an order whose line items have not been written yet.
+ $notification->set_triggered_at( time() );
+
$this->schedule_safety_net( $notification );
if ( ! $this->shutdown_registered ) {
@@ -158,8 +166,9 @@ class PendingNotificationStore {
/**
* Dispatches all pending notifications via InternalNotificationDispatcher.
*
- * Called on shutdown. Sends all pending notifications through the
- * InternalNotificationDispatcher, then clears the store.
+ * Called on shutdown. Records each notification's trigger time, sends all
+ * pending notifications through the InternalNotificationDispatcher, then
+ * clears the store.
*
* @return void
*
@@ -170,12 +179,64 @@ class PendingNotificationStore {
return;
}
+ $this->record_trigger_times();
+
$this->dispatcher->dispatch( array_values( $this->pending ) );
$this->enabled = false;
$this->pending = array();
}
+ /**
+ * Persists each pending notification's trigger time to its resource.
+ *
+ * Runs on shutdown rather than at trigger time so the write stays out of
+ * the order-creation call stack. WordPress fires `shutdown` on fatal errors
+ * too, so the only requests that lose the value are those killed outright
+ * (OOM, SIGKILL), the same requests the safety net exists for, and where
+ * the payload falls back to the current time.
+ *
+ * The recorded time stays that of the first trigger. Core fires the stock
+ * hooks on every reduction past the threshold, not only on the crossing, so
+ * a product that keeps selling while its notification is in flight would
+ * otherwise drag its own recorded time forward and understate its delivery
+ * age. The sent marker is checked as well because a send clears the trigger
+ * time, so a repeat trigger after one would recreate a value that nothing
+ * clears again.
+ *
+ * Failures are caught so that a third-party handler on the resource's save
+ * hooks cannot stop the dispatch below from running.
+ *
+ * @return void
+ *
+ * @since 11.2.0
+ */
+ private function record_trigger_times(): void {
+ foreach ( $this->pending as $notification ) {
+ try {
+ if ( $notification->has_meta( NotificationProcessor::SENT_META_KEY )
+ || $notification->has_meta( NotificationProcessor::TRIGGERED_META_KEY ) ) {
+ continue;
+ }
+
+ $notification->write_meta(
+ NotificationProcessor::TRIGGERED_META_KEY,
+ $notification->get_triggered_at()
+ );
+ } catch ( Throwable $e ) {
+ wc_get_logger()->warning(
+ sprintf(
+ 'Failed to record push notification trigger time (type=%s, resource_id=%d): %s',
+ $notification->get_type(),
+ $notification->get_resource_id(),
+ $e->getMessage()
+ ),
+ array( 'source' => PushNotifications::FEATURE_NAME )
+ );
+ }
+ }
+ }
+
/**
* Returns the number of pending notifications.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php
index 2f9b442fefd..a7b15ca4e20 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php
@@ -5,6 +5,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use WC_Helper_Order;
use WC_Unit_Test_Case;
@@ -12,6 +13,15 @@ use WC_Unit_Test_Case;
* Tests for the NewOrderNotification class.
*/
class NewOrderNotificationTest extends WC_Unit_Test_Case {
+
+ /**
+ * A fixed trigger time and its expected ISO 8601 rendering. Asserting
+ * against a literal keeps the test from recomputing the expected value with
+ * the same `gmdate()` call the payload uses.
+ */
+ private const TRIGGERED_AT = 1700000000;
+ private const TRIGGERED_AT_ISO = '2023-11-14T22:13:20+00:00';
+
/**
* @testdox Should return a payload with all required keys for an existing order.
*/
@@ -91,6 +101,33 @@ class NewOrderNotificationTest extends WC_Unit_Test_Case {
$this->assertNull( $notification->to_payload() );
}
+ /**
+ * @testdox Should use the recorded trigger time for the payload timestamp.
+ */
+ public function test_to_payload_timestamp_uses_recorded_trigger_time(): void {
+ $order = WC_Helper_Order::create_order();
+ $notification = new NewOrderNotification( $order->get_id() );
+
+ $order->update_meta_data( NotificationProcessor::TRIGGERED_META_KEY, (string) self::TRIGGERED_AT );
+ $order->save_meta_data();
+
+ $payload = $notification->to_payload();
+
+ $this->assertSame( self::TRIGGERED_AT_ISO, $payload['timestamp'] );
+ }
+
+ /**
+ * @testdox Should fall back to the current time when no trigger time is recorded.
+ */
+ public function test_to_payload_timestamp_falls_back_to_current_time(): void {
+ $order = WC_Helper_Order::create_order();
+ $notification = new NewOrderNotification( $order->get_id() );
+
+ $payload = $notification->to_payload();
+
+ $this->assertEqualsWithDelta( time(), strtotime( $payload['timestamp'] ), 5 );
+ }
+
/**
* @testdox should_send_to_user should return true when order total exceeds min_amount.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php
index 411e7f217af..7d19000829b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php
@@ -5,6 +5,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use WC_Helper_Product;
use WC_Unit_Test_Case;
@@ -12,6 +13,15 @@ use WC_Unit_Test_Case;
* Tests for the NewReviewNotification class.
*/
class NewReviewNotificationTest extends WC_Unit_Test_Case {
+
+ /**
+ * A fixed trigger time and its expected ISO 8601 rendering. Asserting
+ * against a literal keeps the test from recomputing the expected value with
+ * the same `gmdate()` call the payload uses.
+ */
+ private const TRIGGERED_AT = 1700000000;
+ private const TRIGGERED_AT_ISO = '2023-11-14T22:13:20+00:00';
+
/**
* @testdox Should return a payload with all required keys for an existing review.
*/
@@ -297,6 +307,34 @@ class NewReviewNotificationTest extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Should use the recorded trigger time for the payload timestamp.
+ */
+ public function test_to_payload_timestamp_uses_recorded_trigger_time(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $comment_id = WC_Helper_Product::create_product_review( $product->get_id() );
+ $notification = new NewReviewNotification( $comment_id );
+
+ update_comment_meta( $comment_id, NotificationProcessor::TRIGGERED_META_KEY, (string) self::TRIGGERED_AT );
+
+ $payload = $notification->to_payload();
+
+ $this->assertSame( self::TRIGGERED_AT_ISO, $payload['timestamp'] );
+ }
+
+ /**
+ * @testdox Should fall back to the current time when no trigger time is recorded.
+ */
+ public function test_to_payload_timestamp_falls_back_to_current_time(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $comment_id = WC_Helper_Product::create_product_review( $product->get_id() );
+ $notification = new NewReviewNotification( $comment_id );
+
+ $payload = $notification->to_payload();
+
+ $this->assertEqualsWithDelta( time(), strtotime( $payload['timestamp'] ), 5 );
+ }
+
/**
* Creates a product review with a specific rating stored in comment meta.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/StockNotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/StockNotificationTest.php
index 9bd8544fb3b..4e8354a33fb 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/StockNotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/StockNotificationTest.php
@@ -5,6 +5,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use InvalidArgumentException;
use WC_Helper_Product;
use WC_Unit_Test_Case;
@@ -13,6 +14,15 @@ use WC_Unit_Test_Case;
* Tests for the StockNotification class.
*/
class StockNotificationTest extends WC_Unit_Test_Case {
+
+ /**
+ * A fixed trigger time and its expected ISO 8601 rendering. Asserting
+ * against a literal keeps the test from recomputing the expected value with
+ * the same `gmdate()` call the payload uses.
+ */
+ private const TRIGGERED_AT = 1700000000;
+ private const TRIGGERED_AT_ISO = '2023-11-14T22:13:20+00:00';
+
/**
* @testdox Should return store_stock as the notification type.
*/
@@ -427,6 +437,59 @@ class StockNotificationTest extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Should use the recorded trigger time for the payload timestamp.
+ */
+ public function test_to_payload_timestamp_uses_recorded_trigger_time(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+
+ $product->update_meta_data(
+ NotificationProcessor::TRIGGERED_META_KEY . '_' . StockNotification::EVENT_LOW_STOCK,
+ (string) self::TRIGGERED_AT
+ );
+ $product->save_meta_data();
+
+ $payload = $notification->to_payload();
+
+ $this->assertSame( self::TRIGGERED_AT_ISO, $payload['timestamp'] );
+ }
+
+ /**
+ * Stock notifications scope their meta per event subtype, so a low-stock
+ * trigger time must not leak into an out-of-stock notification for the same
+ * product. The two are separate events with separate delivery ages.
+ *
+ * @testdox Should not reuse another event type's trigger time.
+ */
+ public function test_to_payload_timestamp_is_scoped_per_event_type(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $notification = new StockNotification( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+
+ $product->update_meta_data(
+ NotificationProcessor::TRIGGERED_META_KEY . '_' . StockNotification::EVENT_LOW_STOCK,
+ (string) self::TRIGGERED_AT
+ );
+ $product->save_meta_data();
+
+ $payload = $notification->to_payload();
+
+ $this->assertNotSame( self::TRIGGERED_AT_ISO, $payload['timestamp'] );
+ $this->assertEqualsWithDelta( time(), strtotime( $payload['timestamp'] ), 5 );
+ }
+
+ /**
+ * @testdox Should fall back to the current time when no trigger time is recorded.
+ */
+ public function test_to_payload_timestamp_falls_back_to_current_time(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+
+ $payload = $notification->to_payload();
+
+ $this->assertEqualsWithDelta( time(), strtotime( $payload['timestamp'] ), 5 );
+ }
+
/**
* Data provider mapping event types to their expected title emoji.
*
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 afaded97a07..bdb29c4cbf7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
@@ -142,6 +142,26 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
$this->assertNotEmpty( $order->get_meta( NotificationProcessor::SENT_META_KEY ) );
$this->assertFalse( $notification->has_meta( NotificationProcessor::CLAIMED_META_KEY ) );
+ $this->assertFalse( $notification->has_meta( NotificationProcessor::TRIGGERED_META_KEY ) );
+ }
+
+ /**
+ * The sent marker is the idempotency guard, so it must survive the cleanup
+ * that clears the claimed and triggered markers.
+ *
+ * @testdox Should keep the sent meta when clearing the delivery state.
+ */
+ public function test_reset_processing_meta_keeps_the_sent_marker(): void {
+ $notification = new NewOrderNotification( $this->order_id );
+ $notification->write_meta( NotificationProcessor::SENT_META_KEY );
+ $notification->write_meta( NotificationProcessor::CLAIMED_META_KEY );
+ $notification->write_meta( NotificationProcessor::TRIGGERED_META_KEY );
+
+ $notification->reset_processing_meta();
+
+ $this->assertTrue( $notification->has_meta( NotificationProcessor::SENT_META_KEY ) );
+ $this->assertFalse( $notification->has_meta( NotificationProcessor::CLAIMED_META_KEY ) );
+ $this->assertFalse( $notification->has_meta( NotificationProcessor::TRIGGERED_META_KEY ) );
}
/**
@@ -259,6 +279,8 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
$order = wc_get_order( $this->order_id );
$this->assertNotEmpty( $order->get_meta( NotificationProcessor::SENT_META_KEY ) );
+ $this->assertFalse( $notification->has_meta( NotificationProcessor::CLAIMED_META_KEY ) );
+ $this->assertFalse( $notification->has_meta( NotificationProcessor::TRIGGERED_META_KEY ) );
}
/**
@@ -491,6 +513,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
'to_payload',
'has_meta',
'write_meta',
+ 'read_meta',
'delete_meta',
'should_send_to_user',
)
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 3ba567854de..f2a6794898d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationRetryHandlerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationRetryHandlerTest.php
@@ -248,4 +248,60 @@ class NotificationRetryHandlerTest extends WC_Unit_Test_Case {
$this->assertLogged( 'error', 'Retry failed:', array( 'source' => PushNotifications::FEATURE_NAME ) );
}
+
+ /**
+ * @testdox Should clear the claimed and triggered meta when retries are exhausted.
+ */
+ public function test_schedule_clears_delivery_state_after_max_retries(): void {
+ $notification = new NewOrderNotification( $this->order_id );
+ $notification->write_meta( NotificationProcessor::CLAIMED_META_KEY );
+ $notification->write_meta( NotificationProcessor::TRIGGERED_META_KEY );
+
+ $this->sut->schedule( $notification, null, NotificationRetryHandler::MAX_RETRIES );
+
+ $order = wc_get_order( $this->order_id );
+
+ $this->assertSame( '', $order->get_meta( NotificationProcessor::CLAIMED_META_KEY ) );
+ $this->assertSame( '', $order->get_meta( NotificationProcessor::TRIGGERED_META_KEY ) );
+ }
+
+ /**
+ * @testdox Should clear the claimed and triggered meta when the retry delay exceeds the cap.
+ */
+ public function test_schedule_clears_delivery_state_when_delay_exceeds_max(): void {
+ $notification = new NewOrderNotification( $this->order_id );
+ $notification->write_meta( NotificationProcessor::CLAIMED_META_KEY );
+ $notification->write_meta( NotificationProcessor::TRIGGERED_META_KEY );
+
+ $this->sut->schedule( $notification, NotificationRetryHandler::MAX_RETRY_DELAY + 1, 0 );
+
+ $order = wc_get_order( $this->order_id );
+
+ $this->assertSame( '', $order->get_meta( NotificationProcessor::CLAIMED_META_KEY ) );
+ $this->assertSame( '', $order->get_meta( NotificationProcessor::TRIGGERED_META_KEY ) );
+ }
+
+ /**
+ * @testdox Should clear the claimed and triggered meta when Action Scheduler declines to schedule the retry.
+ */
+ public function test_schedule_clears_delivery_state_when_scheduling_fails(): void {
+ $notification = new NewOrderNotification( $this->order_id );
+ $notification->write_meta( NotificationProcessor::CLAIMED_META_KEY );
+ $notification->write_meta( NotificationProcessor::TRIGGERED_META_KEY );
+
+ $decline = fn() => 0;
+ add_filter( 'pre_as_schedule_single_action', $decline );
+
+ try {
+ $this->sut->schedule( $notification, null, 0 );
+ } finally {
+ remove_filter( 'pre_as_schedule_single_action', $decline );
+ }
+
+ $order = wc_get_order( $this->order_id );
+
+ $this->assertSame( '', $order->get_meta( NotificationProcessor::CLAIMED_META_KEY ) );
+ $this->assertSame( '', $order->get_meta( NotificationProcessor::TRIGGERED_META_KEY ) );
+ $this->assertLogged( 'error', 'retry could not be scheduled', array( 'source' => PushNotifications::FEATURE_NAME ) );
+ }
}
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 876e754637f..528288b2c6a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
@@ -11,6 +11,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotific
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
+use WC_Helper_Order;
use WC_Unit_Test_Case;
/**
@@ -57,6 +58,92 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
$this->assertSame( 1, $this->store->count() );
}
+ /**
+ * Adding must not write to the resource. The write would otherwise land
+ * inside the order-creation call stack, where an HPOS meta save can
+ * escalate into a full order save before line items exist.
+ *
+ * @testdox Should not write to the resource when a notification is added.
+ */
+ public function test_add_does_not_write_to_the_resource(): void {
+ $notification = $this->create_order_mock( 42 );
+ $notification->expects( $this->never() )
+ ->method( 'write_meta' );
+
+ $this->store->add( $notification );
+ }
+
+ /**
+ * Capturing the time is what makes the recorded value the moment the store
+ * event fired rather than the moment of dispatch. Asserted on `add()` itself
+ * because the write happens later, on shutdown.
+ *
+ * @testdox Should capture the trigger time when a notification is added.
+ */
+ public function test_add_captures_the_trigger_time(): void {
+ $notification = $this->create_order_mock( 42 );
+
+ $this->assertNull( $notification->get_triggered_at() );
+
+ $this->store->add( $notification );
+
+ $this->assertEqualsWithDelta( time(), $notification->get_triggered_at(), 10 );
+ }
+
+ /**
+ * @testdox Should record the trigger time on the resource when dispatching.
+ */
+ public function test_dispatch_all_records_trigger_time(): void {
+ $order = WC_Helper_Order::create_order();
+
+ $this->store->add( new NewOrderNotification( $order->get_id() ) );
+ $this->store->dispatch_all();
+
+ $recorded = (int) wc_get_order( $order->get_id() )->get_meta( NotificationProcessor::TRIGGERED_META_KEY );
+
+ $this->assertEqualsWithDelta( time(), $recorded, 10 );
+ }
+
+ /**
+ * The captured time is what gets persisted, so a request that spends time
+ * between the store event and shutdown still records the earlier moment
+ * rather than the moment of the write.
+ *
+ * @testdox Should record the time the event fired, not the time of dispatch.
+ */
+ public function test_dispatch_all_records_the_captured_time_not_the_dispatch_time(): void {
+ $order = WC_Helper_Order::create_order();
+ $notification = new NewOrderNotification( $order->get_id() );
+
+ $this->store->add( $notification );
+ $notification->set_triggered_at( time() - 300 );
+ $this->store->dispatch_all();
+
+ $recorded = (int) wc_get_order( $order->get_id() )->get_meta( NotificationProcessor::TRIGGERED_META_KEY );
+
+ $this->assertEqualsWithDelta( time() - 300, $recorded, 10 );
+ }
+
+ /**
+ * A repeat trigger for an already-sent notification (e.g.
+ * `woocommerce_low_stock` firing on every reduction below the threshold)
+ * must not pay for a meta write that nothing will read.
+ *
+ * @testdox Should not record a trigger time for a notification already sent.
+ */
+ public function test_dispatch_all_skips_notifications_already_sent(): void {
+ $order = WC_Helper_Order::create_order();
+ $notification = new NewOrderNotification( $order->get_id() );
+ $notification->write_meta( NotificationProcessor::SENT_META_KEY );
+
+ $this->store->add( $notification );
+ $this->store->dispatch_all();
+
+ $recorded = wc_get_order( $order->get_id() )->get_meta( NotificationProcessor::TRIGGERED_META_KEY );
+
+ $this->assertSame( '', $recorded );
+ }
+
/**
* @testdox Should deduplicate notifications with the same type and resource ID.
*/