Commit be176194259 for woocommerce
commit be17619425999c7cbae8aa6c6e34c985fec762f8
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date: Fri Oct 2 16:59:45 2026 +0100
Add step logger calls for push notification delivery (#69121)
* Log why each device was or was not sent a push notification
The processor now writes one notification line with the recipient counts and the held-back
reasons, and one line per device saying whether it was cleared or held back and why, so a
support engineer can see that a device was skipped because of the user's preferences instead of
finding nothing. Each notification type reports which preference stopped it through
get_suppression_reason(). Per-device lines stop above 50 eligible tokens, where the counts alone
are kept.
* Record every step a push notification takes from trigger to send
Each notification now logs its trigger, dispatch, receipt, send and retry
outcomes, and every failure that already wrote an error or warning writes the
matching step line too.
* Name the step log steps so support can read them without the code
Customer service reads these steps in a diagnostic tool, where dispatched and
received suggested a device had been reached rather than the loopback request,
and held_back and safety_net needed the code to interpret. Name the loopback
explicitly, split the per-token line into token_included and token_excluded so
whether a token was sent to is readable on its own, and say which exclusions
come from a user's preferences.
* Name tokens in arrays rather than writing a log line for each one
A store holding thousands of push tokens wrote megabytes of step log lines for
a single notification, against a per-source daily ceiling that overwrites
whatever came first, so the record was destroyed within hours on the stores
most worth looking at. Stores above the old cap got no per-device lines at all.
Each notification now writes one line naming its recipients, one naming the
tokens it was not sent to grouped by reason, and one per loopback request
rather than one per notification in the batch.
* Bound every value a push notification step line can carry
The loopback endpoint logs the type of a payload it could not read, and the
exception it logs names that value too, so a caller could put a newline in it
and forge a line the readers parse as genuine. Strip control characters from
every message, cap long values, and cut arrays to 100 entries with a count of
what was left out beside them. Also memoise the token count, which ran once per
notification, and carry the identifier on each batch entry so two stock events
for one product stay distinct.
* Report a fallback job that Action Scheduler refused to schedule
Action Scheduler returns 0 rather than throwing when it cannot store a job, and
the return was ignored, so a notification whose fallback was never scheduled
was logged as though one had been. The trigger line now reports
schedule_failed, which is the only record that nothing will catch the
notification if the loopback does not arrive.
* Keep a logged message whose bytes are not valid UTF-8
A caller reaches an error message through the type it sends to the loopback
endpoint, so the message can hold any bytes at all. One holding a byte that was
not valid UTF-8 was written as an empty string, erasing the record of its own
rejection.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php
index 011ab031ed3..22a05699116 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php
@@ -7,11 +7,13 @@ namespace Automattic\WooCommerce\Internal\PushNotifications\Controllers;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\AuthorizationFailureReason;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
-use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
use Exception;
+use WC_Rate_Limiter;
use WP_Error;
use WP_Http;
use WP_REST_Request;
@@ -33,6 +35,27 @@ class PushNotificationRestController {
*/
const ROUTE_NAMESPACE = 'wc-push-notifications';
+ /**
+ * Rate limit key for the refused loopback line. One key for the whole store,
+ * since an unauthenticated caller can vary anything of its own for free.
+ */
+ const AUTH_FAILURE_LOG_RATE_LIMIT_ID = 'push_notification_auth_failure_log';
+
+ /**
+ * Seconds between refused loopback lines.
+ */
+ const AUTH_FAILURE_LOG_RATE_LIMIT_SECONDS = 60;
+
+ /**
+ * The response message for each authorization failure.
+ */
+ const AUTH_FAILURE_MESSAGES = array(
+ AuthorizationFailureReason::CREDENTIAL_MISSING => 'Missing credential: no Authorization header and no token query parameter.',
+ AuthorizationFailureReason::TOKEN_INVALID => 'Invalid or expired token.',
+ AuthorizationFailureReason::ISSUER_INVALID => 'Invalid token issuer.',
+ AuthorizationFailureReason::BODY_HASH_MISMATCH => 'Body hash mismatch.',
+ );
+
/**
* Registers the REST API route on the rest_api_init hook.
*
@@ -80,10 +103,14 @@ class PushNotificationRestController {
$notifications = is_array( $body ) ? ( $body['notifications'] ?? array() ) : array();
$success_response = new WP_REST_Response( array( 'success' => true ), WP_Http::OK );
+ $step_logger = wc_get_container()->get( NotificationStepLogger::class );
+
if ( empty( $notifications ) || ! is_array( $notifications ) ) {
- wc_get_logger()->warning(
- 'Loopback endpoint received empty or missing notifications array.',
- array( 'source' => PushNotifications::FEATURE_NAME )
+ $step_logger->log_unattributed_failure(
+ 'loopback_started',
+ 'malformed_body',
+ 'warning',
+ 'Loopback endpoint received empty or missing notifications array.'
);
return $success_response;
@@ -94,11 +121,31 @@ class PushNotificationRestController {
foreach ( $notifications as $data ) {
try {
$notification = Notification::from_array( $data );
- $processor->process( $notification );
} catch ( Exception $e ) {
- wc_get_logger()->error(
+ $step_logger->log_unattributed_failure(
+ 'loopback_started',
+ 'invalid_notification',
+ 'error',
sprintf( 'Failed to process notification: %s', $e->getMessage() ),
- array( 'source' => PushNotifications::FEATURE_NAME )
+ array(
+ 'type' => is_array( $data ) ? (string) ( $data['type'] ?? '' ) : '',
+ 'resource_id' => is_array( $data ) ? (int) ( $data['resource_id'] ?? 0 ) : 0,
+ )
+ );
+ continue;
+ }
+
+ $step_logger->log_notification_step( $notification, 'loopback_started', 'ok' );
+
+ try {
+ $processor->process( $notification );
+ } catch ( Exception $e ) {
+ $step_logger->log_failure(
+ $notification,
+ 'processing',
+ 'exception',
+ 'error',
+ sprintf( 'Failed to process notification: %s', $e->getMessage() )
);
}
}
@@ -118,6 +165,29 @@ class PushNotificationRestController {
* @since 10.7.0
*/
public function authorize( WP_REST_Request $request ) {
+ $reason = $this->find_authorization_failure( $request );
+
+ if ( null === $reason ) {
+ return true;
+ }
+
+ $this->log_authorization_failure( $request, $reason );
+
+ return new WP_Error(
+ 'woocommerce_rest_unauthorized',
+ self::AUTH_FAILURE_MESSAGES[ $reason ],
+ array( 'status' => WP_Http::UNAUTHORIZED )
+ );
+ }
+
+ /**
+ * Checks the JWT from the Authorization header against the request.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return string|null One of the AuthorizationFailureReason constants, or null when authorized.
+ */
+ private function find_authorization_failure( WP_REST_Request $request ): ?string {
$header = trim( (string) $request->get_header( 'authorization' ) );
if ( '' !== $header ) {
@@ -127,42 +197,69 @@ class PushNotificationRestController {
}
if ( '' === $token ) {
- return new WP_Error(
- 'woocommerce_rest_unauthorized',
- 'Missing credential: no Authorization header and no token query parameter.',
- array( 'status' => WP_Http::UNAUTHORIZED )
- );
+ return AuthorizationFailureReason::CREDENTIAL_MISSING;
}
if ( ! JsonWebToken::validate( $token, wp_salt( 'auth' ) ) ) {
- return new WP_Error(
- 'woocommerce_rest_unauthorized',
- 'Invalid or expired token.',
- array( 'status' => WP_Http::UNAUTHORIZED )
- );
+ return AuthorizationFailureReason::TOKEN_INVALID;
}
$parts = JsonWebToken::get_parts( $token );
if ( ! isset( $parts->payload->iss ) || get_site_url() !== $parts->payload->iss ) {
- return new WP_Error(
- 'woocommerce_rest_unauthorized',
- 'Invalid token issuer.',
- array( 'status' => WP_Http::UNAUTHORIZED )
- );
+ return AuthorizationFailureReason::ISSUER_INVALID;
}
$body_hash = hash( 'sha256', $request->get_body() );
if ( ! isset( $parts->payload->body_hash ) || ! hash_equals( (string) $parts->payload->body_hash, $body_hash ) ) {
- return new WP_Error(
- 'woocommerce_rest_unauthorized',
- 'Body hash mismatch.',
- array( 'status' => WP_Http::UNAUTHORIZED )
- );
+ return AuthorizationFailureReason::BODY_HASH_MISMATCH;
+ }
+
+ return null;
+ }
+
+ /**
+ * Records a refused loopback request, but only when the body looks like
+ * one of ours.
+ *
+ * The route is public until authorization passes, so logging every refused
+ * request would let anyone write to the store's log by POSTing here. A
+ * stripped header or an expired token still arrives with our body; a
+ * scanner never does.
+ *
+ * The body check alone is not enough, since the source is public and the
+ * route is unauthenticated, so the line is also rate limited store-wide. A
+ * genuine incident still records a line a minute and a flood costs one.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @param string $reason One of the AuthorizationFailureReason constants.
+ * @return void
+ */
+ private function log_authorization_failure( WP_REST_Request $request, string $reason ): void {
+ $body = json_decode( $request->get_body(), true );
+
+ if ( ! is_array( $body ) || ! array_key_exists( 'notifications', $body ) ) {
+ return;
}
- return true;
+ if ( WC_Rate_Limiter::retried_too_soon( self::AUTH_FAILURE_LOG_RATE_LIMIT_ID ) ) {
+ return;
+ }
+
+ WC_Rate_Limiter::set_rate_limit( self::AUTH_FAILURE_LOG_RATE_LIMIT_ID, self::AUTH_FAILURE_LOG_RATE_LIMIT_SECONDS );
+
+ wc_get_container()->get( NotificationStepLogger::class )->log_unattributed_failure(
+ 'loopback_started',
+ 'auth_failed',
+ 'warning',
+ sprintf( 'Loopback request refused: %s', self::AUTH_FAILURE_MESSAGES[ $reason ] ),
+ array(
+ 'reason' => $reason,
+ 'notifications' => is_array( $body['notifications'] ) ? count( $body['notifications'] ) : 0,
+ )
+ );
}
/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index a411f8c3aed..14a2de8287c 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -34,6 +34,15 @@ class PushTokensDataStore {
*/
private array $tokens_by_roles_cache = array();
+ /**
+ * Memoized count_tokens() result for this request. The step log puts it on
+ * every notification's recipients line, and one request can carry thousands
+ * of notifications.
+ *
+ * @var int|null
+ */
+ private ?int $token_count = null;
+
/**
* Memoized has_tokens() result. Null until the first lookup, and reset by create() so a stale false cannot drop a notification.
*
@@ -121,7 +130,8 @@ class PushTokensDataStore {
$push_token->set_id( $id );
- $this->has_tokens = null;
+ $this->has_tokens = null;
+ $this->token_count = null;
return $push_token;
}
@@ -286,6 +296,7 @@ class PushTokensDataStore {
// Anything read earlier in this request now includes deleted tokens.
$this->tokens_by_roles_cache = array();
+ $this->token_count = null;
return $deleted;
}
@@ -432,6 +443,34 @@ class PushTokensDataStore {
return $this->has_tokens;
}
+ /**
+ * Counts every push token on the store, whatever the owner's role.
+ *
+ * Registration requires one of the roles that receive push notifications,
+ * so no token starts out ineligible. A user can lose the role afterwards
+ * though, and their token stays, which is the difference between this count
+ * and what get_tokens_for_roles() returns.
+ *
+ * @since 11.3.0
+ * @return int
+ */
+ public function count_tokens(): int {
+ if ( null !== $this->token_count ) {
+ return $this->token_count;
+ }
+
+ global $wpdb;
+
+ $this->token_count = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private'",
+ PushToken::POST_TYPE
+ )
+ );
+
+ return $this->token_count;
+ }
+
/**
* Returns push tokens belonging to users with the given roles.
*
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php b/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php
index b6e6ce6b220..65b4afa0a15 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php
@@ -7,7 +7,7 @@ namespace Automattic\WooCommerce\Internal\PushNotifications\Dispatchers;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
-use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
/**
@@ -42,6 +42,26 @@ class InternalNotificationDispatcher {
*/
const JWT_EXPIRY_SECONDS = 30;
+ /**
+ * The step logger.
+ *
+ * @var NotificationStepLogger
+ */
+ private NotificationStepLogger $step_logger;
+
+ /**
+ * Initialize injected dependencies.
+ *
+ * @internal
+ *
+ * @param NotificationStepLogger $step_logger The step logger.
+ *
+ * @since 11.3.0
+ */
+ final public function init( NotificationStepLogger $step_logger ): void {
+ $this->step_logger = $step_logger;
+ }
+
/**
* JSON-encodes notifications and fires a non-blocking POST to the internal
* REST endpoint.
@@ -60,10 +80,16 @@ class InternalNotificationDispatcher {
$body = wp_json_encode( array( 'notifications' => $encoded ) );
if ( false === $body ) {
- wc_get_logger()->error(
- 'Failed to JSON-encode push notification payload.',
- array( 'source' => PushNotifications::FEATURE_NAME )
- );
+ foreach ( $notifications as $notification ) {
+ $this->step_logger->log_failure(
+ $notification,
+ 'loopback_requested',
+ 'encode_failed',
+ 'error',
+ 'Failed to JSON-encode push notification payload.',
+ array( 'batch_size' => count( $notifications ) )
+ );
+ }
return;
}
@@ -85,7 +111,7 @@ class InternalNotificationDispatcher {
* parameter: the receiver prefers the header and falls back to the
* parameter on hosts that strip the header (see TOKEN_QUERY_PARAM).
*/
- wp_remote_post(
+ $response = wp_remote_post(
add_query_arg( self::TOKEN_QUERY_PARAM, $token, rest_url( self::SEND_ENDPOINT ) ),
array(
'blocking' => false,
@@ -97,5 +123,38 @@ class InternalNotificationDispatcher {
'body' => $body,
)
);
+
+ /*
+ * One request carries the whole batch, so one line records it, naming
+ * each notification so a per-notification read can find it. The
+ * identifier is carried as well as the type and resource ID, because a
+ * subclass may build it from more: two stock events for one product
+ * share a type and a resource ID and differ only there.
+ */
+ $context = array(
+ 'batch_size' => count( $notifications ),
+ 'notifications' => array_map(
+ fn( Notification $notification ) => array(
+ 'type' => $notification->get_type(),
+ 'resource_id' => $notification->get_resource_id(),
+ 'identifier' => $notification->get_identifier(),
+ ),
+ $notifications
+ ),
+ );
+
+ if ( is_wp_error( $response ) ) {
+ $this->step_logger->log_unattributed_failure(
+ 'loopback_requested',
+ 'request_failed',
+ 'warning',
+ sprintf( 'Loopback request failed: %s', $response->get_error_message() ),
+ $context
+ );
+
+ return;
+ }
+
+ $this->step_logger->log_batch_step( 'loopback_requested', 'ok', $context );
}
}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcher.php b/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcher.php
index 8ba180b397b..10506d3330b 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcher.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcher.php
@@ -8,8 +8,9 @@ defined( 'ABSPATH' ) || exit;
use Automattic\Jetpack\Connection\Client as Jetpack_Connection_Client;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\SendOutcome;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
-use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
use Jetpack_Options;
use WP_Error;
use WP_Http;
@@ -42,12 +43,45 @@ class WpcomNotificationDispatcher {
*/
const REQUEST_TIMEOUT = 15;
+ /**
+ * Error code WPCOM returns when it refused the batch for a rejected token.
+ */
+ const WPCOM_ERROR_INVALID_TOKENS = 'invalid_tokens';
+
+ /**
+ * Error code WPCOM returns when the notification itself failed validation.
+ */
+ const WPCOM_ERROR_INVALID_PARAM = 'rest_invalid_param';
+
+ /**
+ * The step logger.
+ *
+ * @var NotificationStepLogger
+ */
+ private NotificationStepLogger $step_logger;
+
+ /**
+ * Initialize injected dependencies.
+ *
+ * @internal
+ *
+ * @param NotificationStepLogger $step_logger The step logger.
+ *
+ * @since 11.3.0
+ */
+ final public function init( NotificationStepLogger $step_logger ): void {
+ $this->step_logger = $step_logger;
+ }
+
/**
* Dispatches a notification with push tokens to WPCOM.
*
+ * `invalid_tokens` holds the token strings WPCOM refused, exactly as sent,
+ * when the outcome is a token rejection; it is empty otherwise.
+ *
* @param Notification $notification The notification to send.
* @param PushToken[] $tokens The push tokens to send to.
- * @return array{success: bool, retry_after: int|null}
+ * @return array{success: bool, retry_after: int|null, outcome: string, invalid_tokens: string[]}
*
* @since 10.7.0
*/
@@ -55,74 +89,130 @@ class WpcomNotificationDispatcher {
$site_id = class_exists( Jetpack_Options::class ) ? Jetpack_Options::get_option( 'id' ) : null;
if ( empty( $site_id ) ) {
- wc_get_logger()->error(
- 'Cannot send push notifications: Jetpack site ID unavailable.',
- array( 'source' => PushNotifications::FEATURE_NAME )
+ $this->step_logger->log_failure(
+ $notification,
+ 'dispatched',
+ SendOutcome::SITE_ID_MISSING,
+ 'error',
+ 'Cannot send push notifications: Jetpack site ID unavailable.'
);
- return array(
- 'success' => false,
- 'retry_after' => null,
- );
+ return self::failure( SendOutcome::SITE_ID_MISSING );
}
$payload = $notification->to_payload();
if ( null === $payload ) {
- wc_get_logger()->error(
+ $this->step_logger->log_failure(
+ $notification,
+ 'dispatched',
+ SendOutcome::RESOURCE_MISSING,
+ 'error',
sprintf(
'Cannot send push notification: resource no longer exists (type=%s, resource_id=%d).',
$notification->get_type(),
$notification->get_resource_id()
- ),
- array( 'source' => PushNotifications::FEATURE_NAME )
+ )
);
- return array(
- 'success' => false,
- 'retry_after' => null,
- );
+ return self::failure( SendOutcome::RESOURCE_MISSING );
}
$response = $this->make_request( $site_id, $payload, $tokens );
if ( is_wp_error( $response ) ) {
- wc_get_logger()->error(
- sprintf(
- 'Push notification request failed: %s',
- $response->get_error_message()
- ),
- array( 'source' => PushNotifications::FEATURE_NAME )
+ $this->step_logger->log_failure(
+ $notification,
+ 'dispatched',
+ SendOutcome::REQUEST_FAILED,
+ 'error',
+ sprintf( 'Push notification request failed: %s', $response->get_error_message() ),
+ array( 'error_code' => $response->get_error_code() )
);
- return array(
- 'success' => false,
- 'retry_after' => null,
- );
+ return self::failure( SendOutcome::REQUEST_FAILED );
}
$status_code = (int) wp_remote_retrieve_response_code( $response );
+ $body = json_decode( (string) wp_remote_retrieve_body( $response ), true );
+ $body = is_array( $body ) ? $body : array();
if ( WP_Http::OK === $status_code ) {
+ $queued = (int) ( $body['queued'] ?? 0 );
+ $deduplicated = (int) ( $body['deduplicated'] ?? 0 );
+
+ // The endpoint delivers to the first 100 tokens of a request and drops the rest.
+ $dropped = (int) ( $body['dropped'] ?? 0 );
+
+ $outcome = $queued > 0 ? SendOutcome::ACCEPTED : SendOutcome::DEDUPLICATED;
+
+ $this->step_logger->log_notification_step(
+ $notification,
+ 'dispatched',
+ $outcome,
+ array(
+ 'recipients' => count( $tokens ),
+ 'queued' => $queued,
+ 'deduplicated' => $deduplicated,
+ 'dropped' => $dropped,
+ )
+ );
+
return array(
- 'success' => true,
- 'retry_after' => null,
+ 'success' => true,
+ 'retry_after' => null,
+ 'outcome' => $outcome,
+ 'invalid_tokens' => array(),
);
}
- $retry_after = wp_remote_retrieve_header( $response, 'retry-after' );
+ $retry_after = wp_remote_retrieve_header( $response, 'retry-after' );
+ $error_code = (string) ( $body['code'] ?? '' );
+ $invalid_tokens = array();
+ $outcome = SendOutcome::FAILED;
- wc_get_logger()->error(
- sprintf(
- 'Push notification request returned HTTP %d.',
- $status_code
- ),
- array( 'source' => PushNotifications::FEATURE_NAME )
+ if ( self::WPCOM_ERROR_INVALID_TOKENS === $error_code ) {
+ $outcome = SendOutcome::REJECTED_INVALID_TOKEN;
+ $invalid_tokens = array_values( array_filter( (array) ( $body['data']['invalid_tokens'] ?? array() ), 'is_string' ) );
+ } elseif ( self::WPCOM_ERROR_INVALID_PARAM === $error_code ) {
+ $outcome = SendOutcome::REJECTED_INVALID_NOTIFICATION;
+ }
+
+ $this->step_logger->log_failure(
+ $notification,
+ 'dispatched',
+ $outcome,
+ 'error',
+ sprintf( 'Push notification request returned HTTP %d.', $status_code ),
+ array(
+ 'http_status' => $status_code,
+ 'error_code' => $error_code,
+ 'recipients' => count( $tokens ),
+ 'invalid_tokens' => count( $invalid_tokens ),
+ 'retry_after' => '' !== $retry_after ? (int) $retry_after : null,
+ )
);
return array(
- 'success' => false,
- 'retry_after' => '' !== $retry_after ? (int) $retry_after : null,
+ 'success' => false,
+ 'retry_after' => '' !== $retry_after ? (int) $retry_after : null,
+ 'outcome' => $outcome,
+ 'invalid_tokens' => $invalid_tokens,
+ );
+ }
+
+ /**
+ * Builds the return value for a send that failed before or during the request.
+ *
+ * @param string $outcome One of the SendOutcome constants.
+ * @return array{success: bool, retry_after: int|null, outcome: string, invalid_tokens: string[]}
+ */
+ private static function failure( string $outcome ): array {
+ return array(
+ 'success' => false,
+ 'retry_after' => null,
+ 'outcome' => $outcome,
+ 'invalid_tokens' => array(),
);
}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Enums/AuthorizationFailureReason.php b/plugins/woocommerce/src/Internal/PushNotifications/Enums/AuthorizationFailureReason.php
new file mode 100644
index 00000000000..53f8b8c352e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Enums/AuthorizationFailureReason.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Enums;
+
+/**
+ * Reasons a loopback request failed authorization.
+ */
+final class AuthorizationFailureReason {
+
+ /**
+ * Neither the Authorization header nor the token query parameter carried a token.
+ *
+ * @var string
+ */
+ public const CREDENTIAL_MISSING = 'credential_missing';
+
+ /**
+ * The token did not validate against the site's auth salt.
+ *
+ * @var string
+ */
+ public const TOKEN_INVALID = 'token_invalid';
+
+ /**
+ * The token was issued for a different site.
+ *
+ * @var string
+ */
+ public const ISSUER_INVALID = 'issuer_invalid';
+
+ /**
+ * The token's body hash does not match the request body.
+ *
+ * @var string
+ */
+ public const BODY_HASH_MISMATCH = 'body_hash_mismatch';
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Enums/SendOutcome.php b/plugins/woocommerce/src/Internal/PushNotifications/Enums/SendOutcome.php
new file mode 100644
index 00000000000..4a7c4580352
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Enums/SendOutcome.php
@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Enums;
+
+/**
+ * What happened to one attempt at sending a notification.
+ *
+ * The WordPress.com endpoint queues every token in a request or none of them,
+ * so one outcome describes the whole batch.
+ */
+final class SendOutcome {
+
+ /**
+ * WordPress.com queued the notification.
+ *
+ * @var string
+ */
+ public const ACCEPTED = 'accepted';
+
+ /**
+ * WordPress.com had already received this notification and did not queue it again.
+ *
+ * @var string
+ */
+ public const DEDUPLICATED = 'deduplicated';
+
+ /**
+ * WordPress.com refused the request because a token cannot be delivered to.
+ *
+ * @var string
+ */
+ public const REJECTED_INVALID_TOKEN = 'rejected_invalid_token';
+
+ /**
+ * WordPress.com refused the request because the notification itself failed validation.
+ *
+ * @var string
+ */
+ public const REJECTED_INVALID_NOTIFICATION = 'rejected_invalid_notification';
+
+ /**
+ * We could not send, because the store has no Jetpack site ID.
+ *
+ * @var string
+ */
+ public const SITE_ID_MISSING = 'site_id_missing';
+
+ /**
+ * We could not send, because the order, review or product no longer exists.
+ *
+ * @var string
+ */
+ public const RESOURCE_MISSING = 'resource_missing';
+
+ /**
+ * The request to WordPress.com did not complete.
+ *
+ * @var string
+ */
+ public const REQUEST_FAILED = 'request_failed';
+
+ /**
+ * WordPress.com refused the request for a reason it did not name.
+ *
+ * @var string
+ */
+ public const FAILED = 'failed';
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Enums/SuppressionReason.php b/plugins/woocommerce/src/Internal/PushNotifications/Enums/SuppressionReason.php
new file mode 100644
index 00000000000..298c3e174d1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Enums/SuppressionReason.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Enums;
+
+/**
+ * Reasons a notification was not sent to a token.
+ */
+final class SuppressionReason {
+
+ /**
+ * The user turned this notification type off.
+ *
+ * @var string
+ */
+ public const NOTIFICATIONS_OFF = 'notifications_off';
+
+ /**
+ * The user turned off this stock event type.
+ *
+ * @var string
+ */
+ public const STOCK_ALERT_OFF = 'stock_alert_off';
+
+ /**
+ * The order could not be loaded.
+ *
+ * @var string
+ */
+ public const ORDER_MISSING = 'order_missing';
+
+ /**
+ * The order total is below the amount the user asked to be told about.
+ *
+ * @var string
+ */
+ public const BELOW_MIN_AMOUNT = 'below_min_amount';
+
+ /**
+ * The review could not be loaded.
+ *
+ * @var string
+ */
+ public const REVIEW_MISSING = 'review_missing';
+
+ /**
+ * The review's rating is above the maximum the user asked to be told about.
+ *
+ * @var string
+ */
+ public const ABOVE_MAX_RATING = 'above_max_rating';
+
+ /**
+ * The token has no owning user, so there are no preferences to consult.
+ *
+ * @var string
+ */
+ public const NO_ACCOUNT = 'no_account';
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewOrderNotification.php
index 19c03351eba..016c28573c3 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\Enums\SuppressionReason;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use WC_Order;
@@ -91,30 +92,32 @@ class NewOrderNotification extends Notification {
* in mind.
*
* @param mixed $pref_value The user's stored preference value, or null.
- * @return bool
+ * @return string|null One of the SuppressionReason constants, or null to send.
*
- * @since 10.9.0
+ * @since 11.3.0
*/
- public function should_send_to_user( $pref_value ): bool {
- if ( ! parent::should_send_to_user( $pref_value ) ) {
- return false;
+ public function get_suppression_reason( $pref_value ): ?string {
+ $reason = parent::get_suppression_reason( $pref_value );
+
+ if ( null !== $reason ) {
+ return $reason;
}
if ( ! is_array( $pref_value ) || ! isset( $pref_value['min_amount'] ) ) {
- return true;
+ return null;
}
$min_amount = (float) $pref_value['min_amount'];
if ( $min_amount <= 0 ) {
- return true;
+ return null;
}
$order = WC()->call_function( 'wc_get_order', $this->get_resource_id() );
if ( ! $order instanceof WC_Order ) {
- return false;
+ return SuppressionReason::ORDER_MISSING;
}
- return (float) $order->get_total() >= $min_amount;
+ return (float) $order->get_total() >= $min_amount ? null : SuppressionReason::BELOW_MIN_AMOUNT;
}
/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
index 96e949de328..261ab93f417 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/NewReviewNotification.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\PushNotifications\Notifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\SuppressionReason;
use WP_Comment;
defined( 'ABSPATH' ) || exit;
@@ -34,31 +35,33 @@ class NewReviewNotification extends Notification {
* the threshold do not trigger a notification.
*
* @param mixed $pref_value The user's stored preference value, or null.
- * @return bool
+ * @return string|null One of the SuppressionReason constants, or null to send.
*
- * @since 10.9.0
+ * @since 11.3.0
*/
- public function should_send_to_user( $pref_value ): bool {
- if ( ! parent::should_send_to_user( $pref_value ) ) {
- return false;
+ public function get_suppression_reason( $pref_value ): ?string {
+ $reason = parent::get_suppression_reason( $pref_value );
+
+ if ( null !== $reason ) {
+ return $reason;
}
if ( ! is_array( $pref_value ) || ! isset( $pref_value['max_rating'] ) ) {
- return true;
+ return null;
}
$comment = WC()->call_function( 'get_comment', $this->get_resource_id() );
if ( ! $comment instanceof WP_Comment ) {
- return false;
+ return SuppressionReason::REVIEW_MISSING;
}
$rating = WC()->call_function( 'get_comment_meta', $this->get_resource_id(), 'rating', true );
if ( '' === $rating ) {
- return true;
+ return null;
}
- return (int) $rating <= (int) $pref_value['max_rating'];
+ return (int) $rating <= (int) $pref_value['max_rating'] ? null : SuppressionReason::ABOVE_MAX_RATING;
}
/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
index 40c8e554190..e3e5882cff2 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\Enums\SuppressionReason;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use InvalidArgumentException;
@@ -339,8 +340,8 @@ abstract class Notification {
}
/**
- * Decide whether this notification should be delivered to a user given
- * their stored preference value for {@see static::get_type()}.
+ * Returns which preference stops this notification reaching a user, or null
+ * where it should reach them.
*
* `$pref_value` is whatever the user has stored under this notification
* type's preference key, or `null` if they have nothing stored. The
@@ -352,29 +353,48 @@ abstract class Notification {
*
* Default: read the universal `enabled` sub-field, defaulting to `true`
* when the value is missing or has no `enabled` key (so newly-added
- * notification types are opt-in by default). Subclasses override to
- * read richer sub-fields and to consult their own resource (e.g.
- * compare an order total to the user's `min_value`).
+ * notification types are opt-in by default). Subclasses override to read
+ * richer sub-fields and to consult their own resource, returning the first
+ * reason that applies.
*
- * Subclasses must keep this side-effect-free — the {@see NotificationProcessor}
- * may call it once per recipient user per notification.
+ * Subclasses must keep this side-effect-free, since the
+ * {@see NotificationProcessor} may call it once per recipient user per
+ * notification.
*
* @param mixed $pref_value The user's stored preference value, or null.
- * @return bool True if this notification should be sent to that user.
+ * @return string|null One of the SuppressionReason constants, or null to send.
*
- * @since 10.9.0
+ * @since 11.3.0
*/
- public function should_send_to_user( $pref_value ): bool {
+ public function get_suppression_reason( $pref_value ): ?string {
if ( null === $pref_value ) {
- return true;
+ return null;
}
if ( is_array( $pref_value ) ) {
- return (bool) ( $pref_value['enabled'] ?? true );
+ return ( $pref_value['enabled'] ?? true ) ? null : SuppressionReason::NOTIFICATIONS_OFF;
}
// Defensive fallback for unexpected scalar values; the service
// always normalises stored prefs to the array shape above.
- return (bool) $pref_value;
+ return $pref_value ? null : SuppressionReason::NOTIFICATIONS_OFF;
+ }
+
+ /**
+ * Whether this notification should be delivered to a user.
+ *
+ * Kept so a request that loaded older code during a plugin update does not
+ * fatal on a call that has moved. Nothing in the plugin calls it; use
+ * {@see get_suppression_reason()}, which also names why not.
+ *
+ * @deprecated 11.3.0 Use get_suppression_reason() instead.
+ *
+ * @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 {
+ return null === $this->get_suppression_reason( $pref_value );
}
}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php
index 5f31b3669e7..7c283c34968 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\Enums\SuppressionReason;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use InvalidArgumentException;
use WC_Product;
@@ -233,20 +234,22 @@ class StockNotification extends Notification {
* 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
+ * @return string|null One of the SuppressionReason constants, or null to send.
*
- * @since 10.9.0
+ * @since 11.3.0
*/
- public function should_send_to_user( $pref_value ): bool {
- if ( ! parent::should_send_to_user( $pref_value ) ) {
- return false;
+ public function get_suppression_reason( $pref_value ): ?string {
+ $reason = parent::get_suppression_reason( $pref_value );
+
+ if ( null !== $reason ) {
+ return $reason;
}
if ( ! is_array( $pref_value ) || ! array_key_exists( $this->event_type, $pref_value ) ) {
- return true;
+ return null;
}
- return (bool) $pref_value[ $this->event_type ];
+ return $pref_value[ $this->event_type ] ? null : SuppressionReason::STOCK_ALERT_OFF;
}
/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
index e3c9afaed60..2fbd21e7075 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
@@ -7,8 +7,9 @@ namespace Automattic\WooCommerce\Internal\PushNotifications\Services;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
-use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\WpcomNotificationDispatcher;
+use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\SuppressionReason;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Exception;
@@ -63,6 +64,16 @@ class NotificationProcessor {
*/
const SENT_META_KEY = '_wc_push_notification_sent';
+ /**
+ * The most recipient token IDs recorded on a `recipients` line.
+ *
+ * Set to the number the Remote Push Notification Proxy delivers to, since
+ * it drops everything after the first 100 tokens in a request, so IDs past
+ * that one could not have received the notification. The full count is
+ * recorded beside the list either way.
+ */
+ const RECIPIENT_ID_CAP = 100;
+
/**
* The WPCOM dispatcher.
*
@@ -91,6 +102,13 @@ class NotificationProcessor {
*/
private NotificationRetryHandler $retry_handler;
+ /**
+ * The step logger.
+ *
+ * @var NotificationStepLogger
+ */
+ private NotificationStepLogger $step_logger;
+
/**
* Initialize dependencies.
*
@@ -99,7 +117,8 @@ class NotificationProcessor {
* @param WpcomNotificationDispatcher $dispatcher The WPCOM dispatcher.
* @param PushTokensDataStore $data_store The push tokens data store.
* @param NotificationPreferencesService $preferences_service The notification preferences service.
- * @param NotificationRetryHandler $retry_handler The retry handler.
+ * @param NotificationRetryHandler $retry_handler The retry handler.
+ * @param NotificationStepLogger $step_logger The step logger.
*
* @since 10.7.0
*/
@@ -107,12 +126,14 @@ class NotificationProcessor {
WpcomNotificationDispatcher $dispatcher,
PushTokensDataStore $data_store,
NotificationPreferencesService $preferences_service,
- NotificationRetryHandler $retry_handler
+ NotificationRetryHandler $retry_handler,
+ NotificationStepLogger $step_logger
): void {
$this->dispatcher = $dispatcher;
$this->data_store = $data_store;
$this->preferences_service = $preferences_service;
$this->retry_handler = $retry_handler;
+ $this->step_logger = $step_logger;
}
/**
@@ -137,10 +158,16 @@ class NotificationProcessor {
* @since 10.7.0
*/
public function process( Notification $notification, bool $is_retry = false, int $attempt = 0 ): bool {
+ $step_context = array(
+ 'attempt' => $attempt,
+ 'is_retry' => $is_retry,
+ );
+
/**
* This notification has already been sent - don't continue.
*/
if ( $notification->has_meta( self::SENT_META_KEY ) ) {
+ $this->step_logger->log_notification_step( $notification, 'skipped', 'already_sent', $step_context );
return true;
}
@@ -152,6 +179,7 @@ class NotificationProcessor {
* don't continue.
*/
if ( $notification->has_meta( self::CLAIMED_META_KEY ) ) {
+ $this->step_logger->log_notification_step( $notification, 'skipped', 'already_claimed', $step_context );
return true;
}
@@ -161,20 +189,22 @@ class NotificationProcessor {
/**
* Non-paginated result from get_tokens_for_roles.
*
- * @var PushToken[] $tokens
+ * @var PushToken[] $eligible_tokens
*/
- $tokens = $this->data_store->get_tokens_for_roles(
+ $eligible_tokens = $this->data_store->get_tokens_for_roles(
PushNotifications::ROLES_WITH_PUSH_NOTIFICATIONS_ENABLED
);
/**
* Filter out tokens whose owning user does not want this notification.
* The decision is delegated to the notification itself via
- * {@see Notification::should_send_to_user()} so per-type preference
+ * {@see Notification::get_suppression_reason()} so per-type preference
* shapes (simple bool today, parametrized arrays in the future) stay
* encapsulated alongside the type's resource access.
*/
- $tokens = $this->filter_tokens_by_preferences( $tokens, $notification );
+ list( $tokens, $suppression_reasons ) = $this->filter_tokens_by_preferences( $eligible_tokens, $notification );
+
+ $this->log_recipients( $notification, $eligible_tokens, $tokens, $suppression_reasons, $step_context );
/**
* There are no recipients to send to (either no tokens at all, or
@@ -191,6 +221,8 @@ class NotificationProcessor {
$result = $this->dispatcher->dispatch( $notification, $tokens );
+ $this->log_send_outcome( $notification, $tokens, $result, $step_context );
+
if ( ! empty( $result['success'] ) ) {
// Success only, for the reason {@see PushToken::get_last_sent_at_gmt()} gives.
$this->data_store->record_last_sent_at( $tokens );
@@ -210,7 +242,7 @@ class NotificationProcessor {
/**
* Returns the subset of $tokens whose owning user wants $notification.
*
- * The decision is delegated to {@see Notification::should_send_to_user()}
+ * The decision is delegated to {@see Notification::get_suppression_reason()}
* so per-type preference shapes (simple bool today, parametrized arrays
* in the future) stay encapsulated alongside the type's resource access.
* Tokens with no owning user are dropped — there are no preferences to
@@ -221,35 +253,166 @@ class NotificationProcessor {
* browser) and we don't want to re-read user meta or re-fetch the
* resource for every token.
*
+ * Each dropped token is returned with the reason, so the step log can say
+ * why a device did not receive the notification.
+ *
* @param PushToken[] $tokens The tokens to filter.
* @param Notification $notification The notification being processed.
*
- * @return PushToken[] The tokens whose owner wants the notification.
+ * @return array{0: PushToken[], 1: array<int, string>} The tokens whose owner wants the notification, and the reason each dropped token was excluded, as token ID => reason.
*
* @since 10.9.0
*/
private function filter_tokens_by_preferences( array $tokens, Notification $notification ): array {
- $type = $notification->get_type();
- $decision_cache = array();
-
- return array_values(
- array_filter(
- $tokens,
- function ( PushToken $token ) use ( $notification, $type, &$decision_cache ) {
- $user_id = $token->get_user_id();
- if ( ! $user_id ) {
- return false;
- }
-
- if ( ! isset( $decision_cache[ $user_id ] ) ) {
- $prefs = $this->preferences_service->get_preferences( $user_id );
- $decision_cache[ $user_id ] = $notification->should_send_to_user( $prefs[ $type ] ?? null );
- }
-
- return $decision_cache[ $user_id ];
- }
+ $type = $notification->get_type();
+ $decision_cache = array();
+ $kept = array();
+ $suppression_reasons = array();
+
+ foreach ( $tokens as $token ) {
+ $user_id = $token->get_user_id();
+ if ( ! $user_id ) {
+ $suppression_reasons[ (int) $token->get_id() ] = SuppressionReason::NO_ACCOUNT;
+ continue;
+ }
+
+ if ( ! array_key_exists( $user_id, $decision_cache ) ) {
+ $pref_value = $this->preferences_service->get_preferences( $user_id )[ $type ] ?? null;
+ $decision_cache[ $user_id ] = $notification->get_suppression_reason( $pref_value );
+ }
+
+ if ( null === $decision_cache[ $user_id ] ) {
+ $kept[] = $token;
+ } else {
+ $suppression_reasons[ (int) $token->get_id() ] = $decision_cache[ $user_id ];
+ }
+ }
+
+ return array( $kept, $suppression_reasons );
+ }
+
+ /**
+ * Writes the recipient decision to the step log: one line naming the
+ * recipients, and one line naming the tokens that were excluded.
+ *
+ * A store can hold thousands of tokens, and a log source has a daily size
+ * ceiling past which its oldest lines are overwritten without a trace, so
+ * both lines name their tokens in arrays.
+ *
+ * @param Notification $notification The notification being processed.
+ * @param PushToken[] $eligible_tokens Tokens whose owner has a role that receives push notifications.
+ * @param PushToken[] $recipients The tokens the notification will be sent to.
+ * @param array<int, string> $suppression_reasons The reason each dropped token was excluded, as token ID => reason.
+ * @param array $step_context Fields shared by every line of this attempt.
+ * @return void
+ */
+ private function log_recipients(
+ Notification $notification,
+ array $eligible_tokens,
+ array $recipients,
+ array $suppression_reasons,
+ array $step_context
+ ): void {
+ if ( ! $this->step_logger->is_active() ) {
+ return;
+ }
+
+ $recipient_ids = array_map( fn( PushToken $token ) => (int) $token->get_id(), $recipients );
+
+ $context = array_merge(
+ $step_context,
+ array(
+ 'tokens_total' => $this->data_store->count_tokens(),
+ 'tokens_eligible' => count( $eligible_tokens ),
+ 'recipients' => count( $recipients ),
+ 'held_back' => count( $suppression_reasons ),
+ 'held_back_reasons' => array_count_values( $suppression_reasons ),
)
);
+
+ if ( empty( $eligible_tokens ) ) {
+ $this->step_logger->log_notification_step( $notification, 'recipients', 'none', $context );
+ } elseif ( empty( $recipients ) ) {
+ $this->step_logger->log_notification_step( $notification, 'recipients', 'all_excluded', $context );
+ } else {
+ $context['token_ids'] = array_slice( $recipient_ids, 0, self::RECIPIENT_ID_CAP );
+ $this->step_logger->log_notification_step( $notification, 'recipients', 'resolved', $context );
+ }
+
+ $this->log_excluded_tokens( $notification, $suppression_reasons, $step_context );
+ }
+
+ /**
+ * Writes one line naming every token the notification was not sent to,
+ * grouped by the reason each was excluded.
+ *
+ * Grouping by reason rather than naming a reason per token keeps the line
+ * small on a store where most tokens are excluded.
+ *
+ * @param Notification $notification The notification being processed.
+ * @param array<int, string> $suppression_reasons The reason each dropped token was excluded, as token ID => reason.
+ * @param array $step_context Fields shared by every line of this attempt.
+ * @return void
+ */
+ private function log_excluded_tokens( Notification $notification, array $suppression_reasons, array $step_context ): void {
+ if ( empty( $suppression_reasons ) ) {
+ return;
+ }
+
+ $by_reason = array();
+ foreach ( $suppression_reasons as $token_id => $reason ) {
+ $by_reason[ $reason ][] = (int) $token_id;
+ }
+
+ $outcome = 'various';
+ if ( 1 === count( $by_reason ) ) {
+ $outcome = (string) array_key_first( $by_reason );
+ }
+
+ $this->step_logger->log_suppressed_step(
+ $notification,
+ 'token_excluded',
+ $outcome,
+ array_merge( $step_context, array( 'excluded_tokens' => $by_reason ) )
+ );
+ }
+
+ /**
+ * Writes the send outcome WPCOM reported, naming the tokens it refused.
+ *
+ * Every recipient carries the notification's own outcome except the ones
+ * WPCOM named as invalid, so the refused IDs are the whole difference.
+ *
+ * @param Notification $notification The notification being processed.
+ * @param PushToken[] $recipients The tokens the notification was sent to.
+ * @param array $result The dispatcher's return value.
+ * @param array $step_context Fields shared by every line of this attempt.
+ * @return void
+ */
+ private function log_send_outcome( Notification $notification, array $recipients, array $result, array $step_context ): void {
+ if ( ! $this->step_logger->is_active() ) {
+ return;
+ }
+
+ $invalid_tokens = array_flip( $result['invalid_tokens'] ?? array() );
+
+ if ( empty( $invalid_tokens ) ) {
+ return;
+ }
+
+ $invalid_ids = array();
+ foreach ( $recipients as $token ) {
+ if ( isset( $invalid_tokens[ $token->get_token() ] ) ) {
+ $invalid_ids[] = (int) $token->get_id();
+ }
+ }
+
+ $this->step_logger->log_notification_step(
+ $notification,
+ 'dispatched',
+ 'invalid_token',
+ array_merge( $step_context, array( 'invalid_token_ids' => $invalid_ids ) )
+ );
}
/**
@@ -302,19 +465,30 @@ class NotificationProcessor {
$notification = Notification::from_array( $data );
} catch ( Exception $e ) {
- wc_get_logger()->error(
- sprintf( 'Safety net failed: %s', $e->getMessage() ),
- array( 'source' => PushNotifications::FEATURE_NAME )
+ $this->step_logger->log_unattributed_failure(
+ 'fallback',
+ 'invalid_notification',
+ 'error',
+ sprintf( 'Fallback failed: %s', $e->getMessage() ),
+ array(
+ 'type' => $type,
+ 'resource_id' => $resource_id,
+ )
);
return;
}
+ $this->step_logger->log_notification_step( $notification, 'fallback', 'fired' );
+
try {
$this->process( $notification, true );
} catch ( Exception $e ) {
- wc_get_logger()->error(
- sprintf( 'Safety net failed: %s', $e->getMessage() ),
- array( 'source' => PushNotifications::FEATURE_NAME )
+ $this->step_logger->log_failure(
+ $notification,
+ 'fallback',
+ 'exception',
+ 'error',
+ sprintf( 'Fallback failed: %s', $e->getMessage() )
);
$this->retry_handler->schedule( $notification, null, 0 );
}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php
index c52ce7a454d..356cce30a6a 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php
@@ -7,7 +7,6 @@ namespace Automattic\WooCommerce\Internal\PushNotifications\Services;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
-use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Exception;
/**
@@ -55,6 +54,26 @@ class NotificationRetryHandler {
4 => 3600,
);
+ /**
+ * The step logger.
+ *
+ * @var NotificationStepLogger
+ */
+ private NotificationStepLogger $step_logger;
+
+ /**
+ * Initialize injected dependencies.
+ *
+ * @internal
+ *
+ * @param NotificationStepLogger $step_logger The step logger.
+ *
+ * @since 11.3.0
+ */
+ final public function init( NotificationStepLogger $step_logger ): void {
+ $this->step_logger = $step_logger;
+ }
+
/**
* Registers the ActionScheduler hook for retry jobs.
*
@@ -89,14 +108,18 @@ class NotificationRetryHandler {
$next_attempt = max( 0, $current_attempt ) + 1;
if ( $next_attempt > self::MAX_RETRIES || ! isset( self::BACKOFF_SCHEDULE[ $next_attempt ] ) ) {
- wc_get_logger()->error(
+ $this->step_logger->log_failure(
+ $notification,
+ 'retry',
+ 'exhausted',
+ 'error',
sprintf(
'Push notification permanently failed after %d attempts (type=%s, resource_id=%d).',
$next_attempt,
$notification->get_type(),
$notification->get_resource_id()
),
- array( 'source' => PushNotifications::FEATURE_NAME )
+ array( 'attempt' => $current_attempt )
);
$notification->reset_processing_meta();
return;
@@ -105,7 +128,11 @@ class NotificationRetryHandler {
$delay = $retry_after ?? self::BACKOFF_SCHEDULE[ $next_attempt ];
if ( $delay > self::MAX_RETRY_DELAY ) {
- wc_get_logger()->warning(
+ $this->step_logger->log_failure(
+ $notification,
+ 'retry',
+ 'delay_too_long',
+ 'warning',
sprintf(
'Push notification dropped: retry delay %ds exceeds maximum %ds (type=%s, resource_id=%d).',
$delay,
@@ -113,7 +140,10 @@ class NotificationRetryHandler {
$notification->get_type(),
$notification->get_resource_id()
),
- array( 'source' => PushNotifications::FEATURE_NAME )
+ array(
+ 'attempt' => $current_attempt,
+ 'delay' => $delay,
+ )
);
$notification->reset_processing_meta();
return;
@@ -147,17 +177,33 @@ class NotificationRetryHandler {
);
if ( ! $action_id ) {
- wc_get_logger()->error(
+ $this->step_logger->log_failure(
+ $notification,
+ 'retry',
+ 'schedule_failed',
+ '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 )
+ array( 'attempt' => $current_attempt )
);
$notification->reset_processing_meta();
+ return;
}
+
+ $this->step_logger->log_notification_step(
+ $notification,
+ 'retry',
+ 'scheduled',
+ array(
+ 'attempt' => $current_attempt,
+ 'next_attempt' => $next_attempt,
+ 'delay' => $delay,
+ )
+ );
}
/**
@@ -188,9 +234,16 @@ class NotificationRetryHandler {
) + $extra
);
} catch ( Exception $e ) {
- wc_get_logger()->error(
+ $this->step_logger->log_unattributed_failure(
+ 'retry',
+ 'invalid_notification',
+ 'error',
sprintf( 'Retry failed: %s', $e->getMessage() ),
- array( 'source' => PushNotifications::FEATURE_NAME )
+ array(
+ 'type' => $type,
+ 'resource_id' => $resource_id,
+ 'attempt' => $attempt,
+ )
);
return;
}
@@ -202,9 +255,13 @@ class NotificationRetryHandler {
$attempt
);
} catch ( Exception $e ) {
- wc_get_logger()->error(
+ $this->step_logger->log_failure(
+ $notification,
+ 'retry',
+ 'exception',
+ 'error',
sprintf( 'Retry failed: %s', $e->getMessage() ),
- array( 'source' => PushNotifications::FEATURE_NAME )
+ array( 'attempt' => $attempt )
);
$this->schedule( $notification, null, $attempt );
}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php
index 67a07690d9f..dad1dc76841 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php
@@ -15,10 +15,14 @@ use Throwable;
* WooCommerce log lines that Mission Control can read back per notification
* and per device.
*
- * Notification-level steps go to one rolling source per notification type and
- * per-device steps to one rolling source per token, with the notification
- * identifier on every line linking the two. That identifier names the
- * notification's subject rather than one delivery, so a resource firing the
+ * Every line goes to one rolling source per notification type, except the
+ * tokens a notification was not sent to, which share one store-wide source. No
+ * source is per token, so the file count follows the notification types rather
+ * than the devices a store holds; a line names its devices in its context
+ * instead, and the read side turns those back into a row per device.
+ *
+ * The notification identifier on every line links them. That identifier names
+ * the notification's subject rather than one delivery, so a resource firing the
* same type twice in a day repeats it, and a run is read from its trigger step
* to its terminal outcome.
*
@@ -31,9 +35,33 @@ class NotificationStepLogger {
const NOTIFICATION_SOURCE_PREFIX = 'push-notifications-';
/**
- * Source prefix for per-device lines; the token post ID follows.
+ * Source for devices a notification was not sent to.
+ *
+ * One source for the whole store rather than one per device, because the
+ * count follows every token the store holds rather than the hundred a
+ * notification can reach, and a device nobody sent to has a reason rather
+ * than a delivery history to read.
+ */
+ const SUPPRESSED_SOURCE = 'push-suppressed';
+
+ /**
+ * The most characters a line's message may carry.
+ *
+ * Exception messages reach the log and an exception can name a value the
+ * caller sent, so nothing written here is assumed to be short.
+ */
+ const MAX_MESSAGE_LENGTH = 1000;
+
+ /**
+ * The most characters any one context value may carry.
*/
- const TOKEN_SOURCE_PREFIX = 'push-token-';
+ const MAX_VALUE_LENGTH = 200;
+
+ /**
+ * The most entries any one context array may name. What is left out is
+ * counted in a sibling field rather than dropped silently.
+ */
+ const MAX_ARRAY_ITEMS = 100;
/**
* Whether step logging is active for this request, or null until checked.
@@ -46,7 +74,7 @@ class NotificationStepLogger {
* Records a notification-level step.
*
* @param Notification $notification The notification the step belongs to.
- * @param string $step Machine-readable step name, e.g. `cleared_to_send`.
+ * @param string $step Machine-readable step name, e.g. `recipients`.
* @param string $outcome Machine-readable outcome, e.g. `ok` or `no_tokens`.
* @param array $context Extra scalar fields to store with the line.
* @return void
@@ -54,27 +82,26 @@ class NotificationStepLogger {
* @since 11.3.0
*/
public function log_notification_step( Notification $notification, string $step, string $outcome, array $context = array() ): void {
- $this->write( $notification, null, $step, $outcome, $context );
+ $this->write( $notification, $step, $outcome, $context );
}
/**
- * Records a per-device step for one token.
+ * Records a step for the tokens a notification was not sent to.
+ *
+ * Goes to one store-wide source rather than a source per token, so the
+ * file count follows the notification types rather than every token the
+ * store holds. The tokens themselves are named in the context.
*
* @param Notification $notification The notification the step belongs to.
- * @param int $token_id The push token post ID.
- * @param int $user_id The user who owns the token.
- * @param string $step Machine-readable step name, e.g. `held_back`.
- * @param string $outcome Machine-readable outcome, e.g. `type_disabled`.
- * @param array $context Extra scalar fields to store with the line.
+ * @param string $step Machine-readable step name, e.g. `token_excluded`.
+ * @param string $outcome Machine-readable outcome, e.g. `notifications_off`.
+ * @param array $context Extra fields to store with the line.
* @return void
*
* @since 11.3.0
*/
- public function log_token_step( Notification $notification, int $token_id, int $user_id, string $step, string $outcome, array $context = array() ): void {
- $context['token_id'] = $token_id;
- $context['user_id'] = $user_id;
-
- $this->write( $notification, $token_id, $step, $outcome, $context );
+ public function log_suppressed_step( Notification $notification, string $step, string $outcome, array $context = array() ): void {
+ $this->write( $notification, $step, $outcome, $context, true );
}
/**
@@ -88,7 +115,7 @@ class NotificationStepLogger {
* without those fields.
*
* @param Notification $notification The notification the failure belongs to.
- * @param string $step Machine-readable step name, e.g. `send`.
+ * @param string $step Machine-readable step name, e.g. `dispatched`.
* @param string $outcome Machine-readable outcome, e.g. `request_failed`.
* @param string $level `error` or `warning`.
* @param string $message Human-readable message for the error log.
@@ -109,6 +136,38 @@ class NotificationStepLogger {
$this->log_notification_step( $notification, $step, $outcome, $context );
}
+ /**
+ * Records a step that covers several notifications at once, such as the
+ * single loopback request a batch is sent in.
+ *
+ * Written under the module's own source, since a batch can span
+ * notification types and so belongs to no one type's source. The
+ * notifications it covers are named by identifier in the context.
+ *
+ * @param string $step Machine-readable step name, e.g. `loopback_requested`.
+ * @param string $outcome Machine-readable outcome, e.g. `ok`.
+ * @param array $context Extra fields to store with the line.
+ * @return void
+ *
+ * @since 11.3.0
+ */
+ public function log_batch_step( string $step, string $outcome, array $context = array() ): void {
+ try {
+ if ( ! $this->is_active() ) {
+ return;
+ }
+
+ $context['step'] = $step;
+ $context['outcome'] = $outcome;
+ $context['source'] = PushNotifications::FEATURE_NAME;
+ $context['remote-logging'] = false;
+
+ wc_get_logger()->info( self::format_message( $step, $outcome ), self::bound( $context ) );
+ } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+ // Deliberately ignored: a logging failure must not affect the send.
+ }
+ }
+
/**
* Records a failure that happened before a notification could be
* identified, such as a loopback request that failed authorization.
@@ -116,7 +175,7 @@ class NotificationStepLogger {
* Written under the module's own source only, since there is no journey to
* attach it to; the read path includes that source for the days in range.
*
- * @param string $step Machine-readable step name, e.g. `received`.
+ * @param string $step Machine-readable step name, e.g. `loopback_started`.
* @param string $outcome Machine-readable outcome, e.g. `auth_failed`.
* @param string $level `error` or `warning`.
* @param string $message Human-readable message for the error log.
@@ -141,23 +200,26 @@ class NotificationStepLogger {
* @since 11.3.0
*/
public static function get_notification_source( Notification $notification ): string {
- return sanitize_title( self::NOTIFICATION_SOURCE_PREFIX . str_replace( '_', '-', $notification->get_type() ) );
+ return self::get_notification_source_for_type( $notification->get_type() );
}
/**
- * Builds the log source for a push token.
+ * Builds the log source for a notification type name.
*
- * @param int $token_id The push token post ID.
+ * @param string $type The notification type, e.g. `store_order`.
* @return string
*
* @since 11.3.0
*/
- public static function get_token_source( int $token_id ): string {
- return self::TOKEN_SOURCE_PREFIX . $token_id;
+ public static function get_notification_source_for_type( string $type ): string {
+ return sanitize_title( self::NOTIFICATION_SOURCE_PREFIX . str_replace( '_', '-', $type ) );
}
/**
- * Whether step logging is active, decided once per request.
+ * Whether step logging is active, decided once per logger.
+ *
+ * The container resolves one logger per request, so in practice the filter
+ * runs once a request.
*
* A filter callback that throws turns logging off, since callers outside
* this class read the answer without guarding against it.
@@ -175,8 +237,9 @@ class NotificationStepLogger {
/**
* Filters whether push notification step logging is enabled.
*
- * Evaluated once per request. Turning it off stops the journey lines
- * only; the module's error and warning logging is unaffected.
+ * Evaluated once per logger, and the container resolves one per
+ * request. Turning it off stops the journey lines only; the module's
+ * error and warning logging is unaffected.
*
* @since 11.3.0
*
@@ -199,22 +262,23 @@ class NotificationStepLogger {
* get_notification_source() runs sanitize_title() and its public filter.
*
* @param Notification $notification The notification the step belongs to.
- * @param int|null $token_id The push token post ID for a per-device line, or null for a notification-level line.
* @param string $step Machine-readable step name.
* @param string $outcome Machine-readable outcome.
* @param array $context Extra fields to store with the line.
+ * @param bool $suppressed True to write to the store-wide suppressed source instead of the notification type's.
* @return void
*/
- private function write( Notification $notification, ?int $token_id, string $step, string $outcome, array $context ): void {
+ private function write( Notification $notification, string $step, string $outcome, array $context, bool $suppressed = false ): void {
try {
if ( ! $this->is_active() ) {
return;
}
- $context = self::identify( $notification, $step, $outcome, $context );
- $context['source'] = null === $token_id
- ? self::get_notification_source( $notification )
- : self::get_token_source( $token_id );
+ $context = self::bound( self::identify( $notification, $step, $outcome, $context ) );
+
+ $context['source'] = $suppressed
+ ? self::SUPPRESSED_SOURCE
+ : self::get_notification_source( $notification );
wc_get_logger()->info( self::format_message( $step, $outcome ), $context );
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
@@ -235,12 +299,92 @@ class NotificationStepLogger {
$context['source'] = PushNotifications::FEATURE_NAME;
$context['remote-logging'] = false;
- wc_get_logger()->log( $level, $message, $context );
+ wc_get_logger()->log( $level, self::clean( $message, self::MAX_MESSAGE_LENGTH ), self::bound( $context ) );
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Deliberately ignored: a logging failure must not affect the send.
}
}
+ /**
+ * Strips control characters from a string and caps its length.
+ *
+ * A newline in a message would split the entry, since the handler writes
+ * the message into the line unencoded, so a caller able to reach a logged
+ * exception could otherwise forge a line the readers parse as genuine.
+ *
+ * @param string $value The value.
+ * @param int $limit The most characters to keep.
+ * @return string
+ */
+ private static function clean( string $value, int $limit ): string {
+ // Matched bytewise, because a caller controls these bytes and they need
+ // not be valid UTF-8.
+ $value = (string) preg_replace( '/[\x00-\x1F\x7F]+/', ' ', $value );
+
+ if ( mb_strlen( $value ) <= $limit ) {
+ return $value;
+ }
+
+ return mb_substr( $value, 0, $limit ) . '... (truncated)';
+ }
+
+ /**
+ * Caps every value in a context, naming what was left out.
+ *
+ * Arrays are cut to {@see self::MAX_ARRAY_ITEMS} and gain a sibling
+ * counting the entries omitted, so a reader can tell a short list from a
+ * truncated one. A map of lists, such as the excluded tokens grouped by
+ * reason, is cut per list and counted per key.
+ *
+ * @param array $context The context.
+ * @return array
+ */
+ private static function bound( array $context ): array {
+ $bounded = array();
+
+ foreach ( $context as $key => $value ) {
+ if ( is_string( $value ) ) {
+ $bounded[ $key ] = self::clean( $value, self::MAX_VALUE_LENGTH );
+ continue;
+ }
+
+ if ( ! is_array( $value ) ) {
+ $bounded[ $key ] = $value;
+ continue;
+ }
+
+ $omitted = array();
+
+ foreach ( $value as $inner_key => $inner ) {
+ if ( ! is_array( $inner ) ) {
+ continue;
+ }
+
+ $left = count( $inner ) - self::MAX_ARRAY_ITEMS;
+
+ if ( $left > 0 ) {
+ $value[ $inner_key ] = array_slice( $inner, 0, self::MAX_ARRAY_ITEMS );
+ $omitted[ $inner_key ] = $left;
+ }
+ }
+
+ $left = count( $value ) - self::MAX_ARRAY_ITEMS;
+
+ if ( empty( $omitted ) && $left > 0 ) {
+ $value = array_slice( $value, 0, self::MAX_ARRAY_ITEMS );
+ $omitted = $left;
+ }
+
+ $bounded[ $key ] = $value;
+
+ if ( ! empty( $omitted ) ) {
+ $bounded[ $key . '_omitted' ] = $omitted;
+ }
+ }
+
+ return $bounded;
+ }
+
/**
* Adds the fields every line needs to be joined back to its notification.
* The caller's context cannot overwrite them.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
index bfd8e95e6d9..0fc9253223c 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
@@ -61,19 +61,45 @@ class PendingNotificationStore {
*/
private bool $shutdown_registered = false;
+ /**
+ * Notification types that have already recorded that the store holds no tokens.
+ *
+ * A bulk stock write fires a notification per product and the answer is the
+ * same for all of them, so one line per type answers the whole request.
+ * Keyed by type rather than a single flag, because each type writes to its
+ * own log source and a type that recorded nothing would read as never
+ * having been triggered.
+ *
+ * @var array<string, true>
+ */
+ private array $logged_no_tokens = array();
+
+ /**
+ * The step logger.
+ *
+ * @var NotificationStepLogger
+ */
+ private NotificationStepLogger $step_logger;
+
/**
* Initialize dependencies.
*
* @internal
*
- * @param InternalNotificationDispatcher $dispatcher The dispatcher to use on shutdown.
- * @param PushTokensDataStore $data_store The push tokens data store.
+ * @param InternalNotificationDispatcher $dispatcher The dispatcher to use on shutdown.
+ * @param PushTokensDataStore $data_store The push tokens data store.
+ * @param NotificationStepLogger $step_logger The step logger.
*
* @since 10.7.0
*/
- final public function init( InternalNotificationDispatcher $dispatcher, PushTokensDataStore $data_store ): void {
- $this->dispatcher = $dispatcher;
- $this->data_store = $data_store;
+ final public function init(
+ InternalNotificationDispatcher $dispatcher,
+ PushTokensDataStore $data_store,
+ NotificationStepLogger $step_logger
+ ): void {
+ $this->dispatcher = $dispatcher;
+ $this->data_store = $data_store;
+ $this->step_logger = $step_logger;
}
/**
@@ -92,9 +118,10 @@ class PendingNotificationStore {
/**
* Adds a notification to the pending store.
*
- * Duplicate notifications (same type and resource ID) within a single
- * request are silently ignored. The shutdown hook is registered on the
- * first call.
+ * A duplicate (same type and resource ID) within a single request is not
+ * added again, and is recorded as `triggered: duplicate_in_request` so a
+ * reader can tell it apart from one that never arrived. The shutdown hook
+ * is registered on the first call.
*
* Notifications are dropped when no push token is registered, so neither the safety net job nor the loopback request is created for a send that has no recipient.
*
@@ -109,12 +136,20 @@ class PendingNotificationStore {
}
if ( ! $this->data_store->has_tokens() ) {
+ $type = $notification->get_type();
+
+ if ( ! isset( $this->logged_no_tokens[ $type ] ) ) {
+ $this->step_logger->log_notification_step( $notification, 'triggered', 'no_tokens' );
+ $this->logged_no_tokens[ $type ] = true;
+ }
+
return;
}
$key = $notification->get_identifier();
if ( isset( $this->pending[ $key ] ) ) {
+ $this->step_logger->log_notification_step( $notification, 'triggered', 'duplicate_in_request' );
return;
}
@@ -126,7 +161,9 @@ class PendingNotificationStore {
// $order->save() on an order whose line items have not been written yet.
$notification->set_triggered_at( time() );
- $this->schedule_safety_net( $notification );
+ $fallback = $this->schedule_safety_net( $notification );
+
+ $this->step_logger->log_notification_step( $notification, 'triggered', 'queued', array( 'fallback' => $fallback ) );
if ( ! $this->shutdown_registered ) {
add_action( 'shutdown', array( $this, 'dispatch_all' ) );
@@ -141,27 +178,32 @@ class PendingNotificationStore {
* guarantees the notification is still processed.
*
* @param Notification $notification The notification to schedule.
- * @return void
+ * @return string `scheduled`, `already_scheduled`, or `schedule_failed` when Action Scheduler refused the job.
*
* @since 10.7.0
*/
- private function schedule_safety_net( Notification $notification ): void {
+ private function schedule_safety_net( Notification $notification ): string {
// Canonical, identity-keyed args shared with NotificationProcessor::cancel_safety_net().
// Action Scheduler matches stored args by exact equality, so both sides must derive
// them from the same place; see Notification::get_safety_net_args().
$args = $notification->get_safety_net_args();
if ( ActionSchedulerUtil::has_scheduled_action( NotificationProcessor::SAFETY_NET_HOOK, $args, NotificationProcessor::ACTION_SCHEDULER_GROUP ) ) {
- return;
+ return 'already_scheduled';
}
- as_schedule_single_action(
+ // Action Scheduler returns 0 rather than throwing when it cannot store
+ // the job, and a notification whose fallback was never scheduled has
+ // nothing to catch it if the loopback does not arrive.
+ $action_id = as_schedule_single_action(
time() + NotificationProcessor::SAFETY_NET_DELAY,
NotificationProcessor::SAFETY_NET_HOOK,
$args,
NotificationProcessor::ACTION_SCHEDULER_GROUP,
true
);
+
+ return $action_id > 0 ? 'scheduled' : 'schedule_failed';
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushNotificationRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushNotificationRestControllerTest.php
index 2d136eedbe7..febcb6237ec 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushNotificationRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushNotificationRestControllerTest.php
@@ -6,7 +6,11 @@ namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Controllers;
use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationRestController;
use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\AuthorizationFailureReason;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
+use WC_Rate_Limiter;
use WC_Unit_Test_Case;
use WP_REST_Request;
use WP_REST_Server;
@@ -15,6 +19,8 @@ use WP_REST_Server;
* Tests for the PushNotificationRestController class.
*/
class PushNotificationRestControllerTest extends WC_Unit_Test_Case {
+ use LoggerSpyTrait;
+
/**
* REST server used to verify route registration.
@@ -313,4 +319,105 @@ class PushNotificationRestControllerTest extends WC_Unit_Test_Case {
$this->assertSame( 200, $result->get_status() );
$this->assertTrue( $result->get_data()['success'] );
}
+
+ /**
+ * @testdox Should record one refused request per rate limit window, however many arrive.
+ */
+ public function test_authorize_rate_limits_the_refusal_line(): void {
+ $body = '{"notifications":[{"type":"store_order","resource_id":1}]}';
+
+ $first = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+ $first->set_body( $body );
+ $this->sut->authorize( $first );
+
+ $this->assertCount( 1, $this->get_logs_by_level( 'warning' ) );
+
+ $second = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+ $second->set_body( $body );
+ $this->sut->authorize( $second );
+
+ $this->assertCount( 1, $this->get_logs_by_level( 'warning' ) );
+ }
+
+ /**
+ * @testdox Should log a refused request only when the body carries a notifications key.
+ */
+ public function test_authorize_logs_refusals_only_for_loopback_shaped_bodies(): void {
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+ $request->set_body( '{"notifications":[{"type":"store_order","resource_id":1}]}' );
+ $this->sut->authorize( $request );
+
+ $this->assertLogged(
+ 'warning',
+ 'Loopback request refused: Missing credential',
+ array(
+ 'source' => PushNotifications::FEATURE_NAME,
+ 'step' => 'loopback_started',
+ 'outcome' => 'auth_failed',
+ 'reason' => AuthorizationFailureReason::CREDENTIAL_MISSING,
+ 'notifications' => 1,
+ )
+ );
+ }
+
+ /**
+ * The rate limit would also stop the line, so this puts the next allowed
+ * time in the past first. Without that the test passes whether or not the
+ * body is checked at all, since the limiter returns early either way.
+ *
+ * @testdox Should not log a refused request whose body is not ours, even when nothing is rate limited.
+ */
+ public function test_authorize_does_not_log_a_refusal_without_our_body(): void {
+ WC_Rate_Limiter::set_rate_limit( PushNotificationRestController::AUTH_FAILURE_LOG_RATE_LIMIT_ID, -60 );
+ $this->captured_logs = array();
+
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+ $request->set_body( '{"anything":"else"}' );
+ $this->sut->authorize( $request );
+
+ $this->assertSame( array(), $this->captured_logs, 'A refused request without our body must not be logged.' );
+ }
+
+ /**
+ * @testdox Should log a malformed body with the step fields.
+ */
+ public function test_create_logs_malformed_body(): void {
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+ $request->set_body( '{}' );
+
+ $this->sut->create( $request );
+
+ $this->assertLogged(
+ 'warning',
+ 'empty or missing notifications array',
+ array(
+ 'source' => PushNotifications::FEATURE_NAME,
+ 'step' => 'loopback_started',
+ 'outcome' => 'malformed_body',
+ )
+ );
+ }
+
+ /**
+ * @testdox Should log a notification that cannot be built, with the type and resource it named.
+ */
+ public function test_create_logs_invalid_notification(): void {
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+ $request->set_body( '{"notifications":[{"type":"unknown_type","resource_id":7}]}' );
+
+ $result = $this->sut->create( $request );
+
+ $this->assertSame( 200, $result->get_status() );
+ $this->assertLogged(
+ 'error',
+ 'Failed to process notification:',
+ array(
+ 'source' => PushNotifications::FEATURE_NAME,
+ 'step' => 'loopback_started',
+ 'outcome' => 'invalid_notification',
+ 'type' => 'unknown_type',
+ 'resource_id' => 7,
+ )
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php
index 3bb04d41ed9..8fbe88503e7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Dispatchers;
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\Services\NotificationStepLogger;
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
use WC_Unit_Test_Case;
@@ -36,13 +37,22 @@ class InternalNotificationDispatcherTest extends WC_Unit_Test_Case {
*/
private $captured_url;
+ /**
+ * Mock step logger.
+ *
+ * @var NotificationStepLogger|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $step_logger;
+
/**
* Set up test fixtures.
*/
public function setUp(): void {
parent::setUp();
- $this->sut = new InternalNotificationDispatcher();
+ $this->step_logger = $this->createMock( NotificationStepLogger::class );
+ $this->sut = new InternalNotificationDispatcher();
+ $this->sut->init( $this->step_logger );
$this->captured_request = null;
$this->captured_url = null;
@@ -195,6 +205,46 @@ class InternalNotificationDispatcherTest extends WC_Unit_Test_Case {
$this->assertNull( $this->captured_url, 'No HTTP request should be made for empty notifications' );
}
+ /**
+ * @testdox Should log one line for the batch, naming every notification it carried.
+ */
+ public function test_dispatch_logs_one_line_for_the_batch(): void {
+ $this->step_logger->expects( $this->once() )
+ ->method( 'log_batch_step' )
+ ->with(
+ 'loopback_requested',
+ 'ok',
+ $this->callback(
+ fn( array $context ) => 2 === $context['batch_size']
+ && array( 'store_order', 'store_order' ) === array_column( $context['notifications'], 'type' )
+ && array( 1, 2 ) === array_column( $context['notifications'], 'resource_id' )
+ && 2 === count( array_filter( array_column( $context['notifications'], 'identifier' ) ) )
+ )
+ );
+
+ $this->sut->dispatch( array( $this->create_order_mock( 1 ), $this->create_order_mock( 2 ) ) );
+ }
+
+ /**
+ * @testdox Should log one failure for the batch when the loopback request cannot be made.
+ */
+ public function test_dispatch_logs_request_failure(): void {
+ remove_filter( 'pre_http_request', array( $this, 'intercept_http_request' ), 10 );
+ add_filter( 'pre_http_request', fn() => new \WP_Error( 'http_request_failed', 'cURL error 7' ) );
+
+ $this->step_logger->expects( $this->once() )
+ ->method( 'log_unattributed_failure' )
+ ->with(
+ 'loopback_requested',
+ 'request_failed',
+ 'warning',
+ 'Loopback request failed: cURL error 7',
+ $this->callback( fn( array $context ) => 1 === $context['batch_size'] )
+ );
+
+ $this->sut->dispatch( array( $this->create_order_mock( 1 ) ) );
+ }
+
/**
* Creates a mock NewOrderNotification that avoids database calls.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcherTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcherTest.php
index c77519fb94c..604a57a4ff2 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcherTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/WpcomNotificationDispatcherTest.php
@@ -6,7 +6,9 @@ namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Dispatchers;
use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\WpcomNotificationDispatcher;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\SendOutcome;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
use WC_Unit_Test_Case;
use WP_Error;
@@ -43,13 +45,22 @@ class WpcomNotificationDispatcherTest extends WC_Unit_Test_Case {
*/
private ?string $captured_url;
+ /**
+ * Mock step logger.
+ *
+ * @var NotificationStepLogger|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $step_logger;
+
/**
* Set up test fixtures.
*/
public function setUp(): void {
parent::setUp();
- $this->sut = new WpcomNotificationDispatcher();
+ $this->step_logger = $this->createMock( NotificationStepLogger::class );
+ $this->sut = new WpcomNotificationDispatcher();
+ $this->sut->init( $this->step_logger );
$this->mock_response = $this->make_response( 200 );
$this->captured_request = null;
$this->captured_url = null;
@@ -258,20 +269,175 @@ class WpcomNotificationDispatcherTest extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Should report every token accepted when WPCOM queued the batch.
+ */
+ public function test_dispatch_logs_accepted_when_wpcom_queued_the_batch(): void {
+ $this->mock_response = $this->make_response( 200, array(), '{"queued":1}' );
+
+ $this->step_logger->expects( $this->once() )
+ ->method( 'log_notification_step' )
+ ->with(
+ $this->anything(),
+ 'dispatched',
+ SendOutcome::ACCEPTED,
+ array(
+ 'recipients' => 1,
+ 'queued' => 1,
+ 'deduplicated' => 0,
+ 'dropped' => 0,
+ )
+ );
+
+ $result = $this->sut->dispatch( $this->create_notification(), $this->create_tokens() );
+
+ $this->assertTrue( $result['success'] );
+ $this->assertSame( SendOutcome::ACCEPTED, $result['outcome'] );
+ $this->assertSame( array(), $result['invalid_tokens'] );
+ }
+
+ /**
+ * @testdox Should record how many devices WordPress.com dropped from the request.
+ */
+ public function test_dispatch_records_the_dropped_count(): void {
+ $this->mock_response = $this->make_response( 200, array(), '{"queued":100,"deduplicated":0,"dropped":40}' );
+
+ $this->step_logger->expects( $this->once() )
+ ->method( 'log_notification_step' )
+ ->with(
+ $this->anything(),
+ 'dispatched',
+ SendOutcome::ACCEPTED,
+ $this->callback(
+ function ( array $context ) {
+ return 40 === $context['dropped'] && 100 === $context['queued'];
+ }
+ )
+ );
+
+ $result = $this->sut->dispatch( $this->create_notification(), $this->create_tokens() );
+
+ $this->assertTrue( $result['success'] );
+ }
+
+ /**
+ * @testdox Should treat a batch refused by the deduplication window as sent, with a deduplicated outcome.
+ */
+ public function test_dispatch_logs_deduplicated_when_wpcom_queued_nothing(): void {
+ $this->mock_response = $this->make_response( 200, array(), '{"queued":0,"deduplicated":1}' );
+
+ $this->step_logger->expects( $this->once() )
+ ->method( 'log_notification_step' )
+ ->with( $this->anything(), 'dispatched', SendOutcome::DEDUPLICATED );
+
+ $result = $this->sut->dispatch( $this->create_notification(), $this->create_tokens() );
+
+ $this->assertTrue( $result['success'] );
+ $this->assertSame( SendOutcome::DEDUPLICATED, $result['outcome'] );
+ }
+
+ /**
+ * @testdox Should name the refused tokens when WPCOM rejects the batch for an invalid token.
+ */
+ public function test_dispatch_returns_the_invalid_tokens_wpcom_named(): void {
+ $this->mock_response = $this->make_response(
+ 422,
+ array(),
+ '{"code":"invalid_tokens","message":"One or more tokens are invalid.","data":{"status":422,"invalid_tokens":["test-token"]}}'
+ );
+
+ $this->step_logger->expects( $this->once() )
+ ->method( 'log_failure' )
+ ->with(
+ $this->anything(),
+ 'dispatched',
+ SendOutcome::REJECTED_INVALID_TOKEN,
+ 'error',
+ 'Push notification request returned HTTP 422.',
+ $this->callback(
+ fn( array $context ) => 422 === $context['http_status']
+ && 'invalid_tokens' === $context['error_code']
+ && 1 === $context['invalid_tokens']
+ )
+ );
+
+ $result = $this->sut->dispatch( $this->create_notification(), $this->create_tokens() );
+
+ $this->assertFalse( $result['success'] );
+ $this->assertSame( SendOutcome::REJECTED_INVALID_TOKEN, $result['outcome'] );
+ $this->assertSame( array( 'test-token' ), $result['invalid_tokens'] );
+ }
+
+ /**
+ * @testdox Should report a rejected notification, naming no tokens, when WPCOM fails validation.
+ */
+ public function test_dispatch_reports_rejected_notification_on_rest_invalid_param(): void {
+ $this->mock_response = $this->make_response( 400, array(), '{"code":"rest_invalid_param","message":"Invalid parameter(s): resource_id"}' );
+
+ $result = $this->sut->dispatch( $this->create_notification(), $this->create_tokens() );
+
+ $this->assertFalse( $result['success'] );
+ $this->assertSame( SendOutcome::REJECTED_INVALID_NOTIFICATION, $result['outcome'] );
+ $this->assertSame( array(), $result['invalid_tokens'] );
+ }
+
+ /**
+ * @testdox Should report a plain failure with the status and retry delay on any other error.
+ */
+ public function test_dispatch_reports_failure_with_status_and_retry_after(): void {
+ $this->mock_response = $this->make_response( 503, array( 'retry-after' => '120' ) );
+
+ $this->step_logger->expects( $this->once() )
+ ->method( 'log_failure' )
+ ->with(
+ $this->anything(),
+ 'dispatched',
+ SendOutcome::FAILED,
+ 'error',
+ $this->anything(),
+ $this->callback( fn( array $context ) => 503 === $context['http_status'] && 120 === $context['retry_after'] )
+ );
+
+ $result = $this->sut->dispatch( $this->create_notification(), $this->create_tokens() );
+
+ $this->assertSame( SendOutcome::FAILED, $result['outcome'] );
+ $this->assertSame( 120, $result['retry_after'] );
+ }
+
+ /**
+ * @testdox Should log the missing site ID and the missing resource as failures.
+ */
+ public function test_dispatch_logs_failures_before_the_request(): void {
+ $this->step_logger->expects( $this->exactly( 2 ) )
+ ->method( 'log_failure' )
+ ->withConsecutive(
+ array( $this->anything(), 'dispatched', SendOutcome::RESOURCE_MISSING, 'error' ),
+ array( $this->anything(), 'dispatched', SendOutcome::REQUEST_FAILED, 'error' )
+ );
+
+ $result = $this->sut->dispatch( $this->create_notification( null ), $this->create_tokens() );
+ $this->assertSame( SendOutcome::RESOURCE_MISSING, $result['outcome'] );
+
+ $this->mock_response = new WP_Error( 'http_request_failed', 'Connection timed out' );
+ $result = $this->sut->dispatch( $this->create_notification(), $this->create_tokens() );
+ $this->assertSame( SendOutcome::REQUEST_FAILED, $result['outcome'] );
+ }
+
/**
* Creates a mock HTTP response array.
*
- * @param int $status_code HTTP status code.
- * @param array $headers Response headers.
+ * @param int $status_code HTTP status code.
+ * @param array $headers Response headers.
+ * @param string $body Response body.
* @return array
*/
- private function make_response( int $status_code, array $headers = array() ): array {
+ private function make_response( int $status_code, array $headers = array(), string $body = '' ): array {
return array(
'response' => array(
'code' => $status_code,
'message' => 'Mock',
),
- 'body' => '',
+ 'body' => $body,
'headers' => $headers,
);
}
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 a7b15ca4e20..c5c5687f4c0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewOrderNotificationTest.php
@@ -4,7 +4,9 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\SuppressionReason;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use WC_Helper_Order;
use WC_Unit_Test_Case;
@@ -129,15 +131,15 @@ class NewOrderNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return true when order total exceeds min_amount.
+ * @testdox get_suppression_reason should return null when order total exceeds min_amount.
*/
- public function test_should_send_to_user_when_order_total_above_threshold(): void {
+ public function test_suppression_reason_when_order_total_above_threshold(): void {
$order = $this->create_order_with_total( 100 );
$notification = new NewOrderNotification( $order->get_id() );
- $this->assertTrue(
- $notification->should_send_to_user(
+ $this->assertNull(
+ $notification->get_suppression_reason(
array(
'enabled' => true,
'min_amount' => 50,
@@ -147,15 +149,15 @@ class NewOrderNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return true when order total equals min_amount.
+ * @testdox get_suppression_reason should return null when order total equals min_amount.
*/
- public function test_should_send_to_user_when_order_total_equals_threshold(): void {
+ public function test_suppression_reason_when_order_total_equals_threshold(): void {
$order = $this->create_order_with_total( 50 );
$notification = new NewOrderNotification( $order->get_id() );
- $this->assertTrue(
- $notification->should_send_to_user(
+ $this->assertNull(
+ $notification->get_suppression_reason(
array(
'enabled' => true,
'min_amount' => 50,
@@ -165,15 +167,15 @@ class NewOrderNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return false when order total is below min_amount.
+ * @testdox get_suppression_reason should return a reason when order total is below min_amount.
*/
public function test_should_not_send_to_user_when_order_total_below_threshold(): void {
$order = $this->create_order_with_total( 30 );
$notification = new NewOrderNotification( $order->get_id() );
- $this->assertFalse(
- $notification->should_send_to_user(
+ $this->assertNotNull(
+ $notification->get_suppression_reason(
array(
'enabled' => true,
'min_amount' => 50,
@@ -183,15 +185,15 @@ class NewOrderNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return true when min_amount is null (no threshold).
+ * @testdox get_suppression_reason should return null when min_amount is null (no threshold).
*/
- public function test_should_send_to_user_when_min_amount_is_null(): void {
+ public function test_suppression_reason_when_min_amount_is_null(): void {
$order = $this->create_order_with_total( 1 );
$notification = new NewOrderNotification( $order->get_id() );
- $this->assertTrue(
- $notification->should_send_to_user(
+ $this->assertNull(
+ $notification->get_suppression_reason(
array(
'enabled' => true,
'min_amount' => null,
@@ -201,15 +203,15 @@ class NewOrderNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return false when notification is disabled, regardless of amount.
+ * @testdox get_suppression_reason should return a reason when notification is disabled, regardless of amount.
*/
public function test_should_not_send_to_user_when_disabled(): void {
$order = $this->create_order_with_total( 1000 );
$notification = new NewOrderNotification( $order->get_id() );
- $this->assertFalse(
- $notification->should_send_to_user(
+ $this->assertNotNull(
+ $notification->get_suppression_reason(
array(
'enabled' => false,
'min_amount' => null,
@@ -219,18 +221,68 @@ class NewOrderNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return true when min_amount key is missing (backwards compat).
+ * @testdox get_suppression_reason should return null when min_amount key is missing (backwards compat).
*/
- public function test_should_send_to_user_when_min_amount_missing(): void {
+ public function test_suppression_reason_when_min_amount_missing(): void {
$order = $this->create_order_with_total( 1 );
$notification = new NewOrderNotification( $order->get_id() );
- $this->assertTrue(
- $notification->should_send_to_user( array( 'enabled' => true ) )
+ $this->assertNull(
+ $notification->get_suppression_reason( array( 'enabled' => true ) )
);
}
+ /**
+ * @testdox get_suppression_reason should name the type toggle when it is off, before any amount check.
+ */
+ public function test_get_suppression_reason_type_disabled_wins(): void {
+ $order = $this->create_order_with_total( 10 );
+ $notification = new NewOrderNotification( $order->get_id() );
+
+ $reason = $notification->get_suppression_reason(
+ array(
+ 'enabled' => false,
+ 'min_amount' => 50,
+ )
+ );
+
+ $this->assertSame( SuppressionReason::NOTIFICATIONS_OFF, $reason );
+ }
+
+ /**
+ * @testdox get_suppression_reason should name the minimum amount when the order total is below it.
+ */
+ public function test_get_suppression_reason_below_min_amount(): void {
+ $order = $this->create_order_with_total( 10 );
+ $notification = new NewOrderNotification( $order->get_id() );
+
+ $reason = $notification->get_suppression_reason(
+ array(
+ 'enabled' => true,
+ 'min_amount' => 50,
+ )
+ );
+
+ $this->assertSame( SuppressionReason::BELOW_MIN_AMOUNT, $reason );
+ }
+
+ /**
+ * @testdox get_suppression_reason should report a missing order when it cannot be loaded.
+ */
+ public function test_get_suppression_reason_order_missing(): void {
+ $notification = new NewOrderNotification( 999999 );
+
+ $reason = $notification->get_suppression_reason(
+ array(
+ 'enabled' => true,
+ 'min_amount' => 50,
+ )
+ );
+
+ $this->assertSame( SuppressionReason::ORDER_MISSING, $reason );
+ }
+
/**
* Creates an order with a specific total.
*
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 7d19000829b..aaac33991a4 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NewReviewNotificationTest.php
@@ -4,7 +4,9 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\SuppressionReason;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use WC_Helper_Product;
use WC_Unit_Test_Case;
@@ -167,15 +169,15 @@ class NewReviewNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return true when rating is below max_rating.
+ * @testdox get_suppression_reason should return null when rating is below max_rating.
*/
- public function test_should_send_to_user_when_rating_below_max(): void {
+ public function test_suppression_reason_when_rating_below_max(): void {
$comment_id = $this->create_review_with_rating( 3 );
$notification = new NewReviewNotification( $comment_id );
- $this->assertTrue(
- $notification->should_send_to_user(
+ $this->assertNull(
+ $notification->get_suppression_reason(
array(
'enabled' => true,
'max_rating' => 4,
@@ -185,15 +187,15 @@ class NewReviewNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return true when rating equals max_rating.
+ * @testdox get_suppression_reason should return null when rating equals max_rating.
*/
- public function test_should_send_to_user_when_rating_equals_max(): void {
+ public function test_suppression_reason_when_rating_equals_max(): void {
$comment_id = $this->create_review_with_rating( 3 );
$notification = new NewReviewNotification( $comment_id );
- $this->assertTrue(
- $notification->should_send_to_user(
+ $this->assertNull(
+ $notification->get_suppression_reason(
array(
'enabled' => true,
'max_rating' => 3,
@@ -203,15 +205,15 @@ class NewReviewNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return false when rating is above max_rating.
+ * @testdox get_suppression_reason should return a reason when rating is above max_rating.
*/
public function test_should_not_send_to_user_when_rating_above_max(): void {
$comment_id = $this->create_review_with_rating( 5 );
$notification = new NewReviewNotification( $comment_id );
- $this->assertFalse(
- $notification->should_send_to_user(
+ $this->assertNotNull(
+ $notification->get_suppression_reason(
array(
'enabled' => true,
'max_rating' => 3,
@@ -221,28 +223,28 @@ class NewReviewNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return true when max_rating key is missing (backwards compat).
+ * @testdox get_suppression_reason should return null when max_rating key is missing (backwards compat).
*/
- public function test_should_send_to_user_when_max_rating_missing(): void {
+ public function test_suppression_reason_when_max_rating_missing(): void {
$comment_id = $this->create_review_with_rating( 5 );
$notification = new NewReviewNotification( $comment_id );
- $this->assertTrue(
- $notification->should_send_to_user( array( 'enabled' => true ) )
+ $this->assertNull(
+ $notification->get_suppression_reason( array( 'enabled' => true ) )
);
}
/**
- * @testdox should_send_to_user should return true when max_rating is null (threshold disabled).
+ * @testdox get_suppression_reason should return null when max_rating is null (threshold disabled).
*/
- public function test_should_send_to_user_when_max_rating_null(): void {
+ public function test_suppression_reason_when_max_rating_null(): void {
$comment_id = $this->create_review_with_rating( 5 );
$notification = new NewReviewNotification( $comment_id );
- $this->assertTrue(
- $notification->should_send_to_user(
+ $this->assertNull(
+ $notification->get_suppression_reason(
array(
'enabled' => true,
'max_rating' => null,
@@ -252,15 +254,15 @@ class NewReviewNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return false when notification is disabled, regardless of rating.
+ * @testdox get_suppression_reason should return a reason when notification is disabled, regardless of rating.
*/
public function test_should_not_send_to_user_when_disabled(): void {
$comment_id = $this->create_review_with_rating( 1 );
$notification = new NewReviewNotification( $comment_id );
- $this->assertFalse(
- $notification->should_send_to_user(
+ $this->assertNotNull(
+ $notification->get_suppression_reason(
array(
'enabled' => false,
'max_rating' => null,
@@ -270,13 +272,13 @@ class NewReviewNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return false when the comment no longer exists.
+ * @testdox get_suppression_reason should return a reason when the comment no longer exists.
*/
public function test_should_not_send_to_user_when_comment_deleted(): void {
$notification = new NewReviewNotification( 999999 );
- $this->assertFalse(
- $notification->should_send_to_user(
+ $this->assertNotNull(
+ $notification->get_suppression_reason(
array(
'enabled' => true,
'max_rating' => 3,
@@ -286,9 +288,9 @@ class NewReviewNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return true when the review has no rating meta.
+ * @testdox get_suppression_reason should return null when the review has no rating meta.
*/
- public function test_should_send_to_user_when_no_rating_meta(): void {
+ public function test_suppression_reason_when_no_rating_meta(): void {
$product = WC_Helper_Product::create_simple_product();
$comment_id = WC_Helper_Product::create_product_review( $product->get_id() );
@@ -297,8 +299,8 @@ class NewReviewNotificationTest extends WC_Unit_Test_Case {
$notification = new NewReviewNotification( $comment_id );
- $this->assertTrue(
- $notification->should_send_to_user(
+ $this->assertNull(
+ $notification->get_suppression_reason(
array(
'enabled' => true,
'max_rating' => 3,
@@ -335,6 +337,54 @@ class NewReviewNotificationTest extends WC_Unit_Test_Case {
$this->assertEqualsWithDelta( time(), strtotime( $payload['timestamp'] ), 5 );
}
+ /**
+ * @testdox get_suppression_reason should name the type toggle when it is off, before any rating check.
+ */
+ public function test_get_suppression_reason_type_disabled_wins(): void {
+ $notification = new NewReviewNotification( $this->create_review_with_rating( 5 ) );
+
+ $reason = $notification->get_suppression_reason(
+ array(
+ 'enabled' => false,
+ 'max_rating' => 3,
+ )
+ );
+
+ $this->assertSame( SuppressionReason::NOTIFICATIONS_OFF, $reason );
+ }
+
+ /**
+ * @testdox get_suppression_reason should name the maximum rating when the review is above it.
+ */
+ public function test_get_suppression_reason_above_max_rating(): void {
+ $notification = new NewReviewNotification( $this->create_review_with_rating( 5 ) );
+
+ $reason = $notification->get_suppression_reason(
+ array(
+ 'enabled' => true,
+ 'max_rating' => 3,
+ )
+ );
+
+ $this->assertSame( SuppressionReason::ABOVE_MAX_RATING, $reason );
+ }
+
+ /**
+ * @testdox get_suppression_reason should report a missing comment when it cannot be loaded.
+ */
+ public function test_get_suppression_reason_review_missing(): void {
+ $notification = new NewReviewNotification( 999999 );
+
+ $reason = $notification->get_suppression_reason(
+ array(
+ 'enabled' => true,
+ 'max_rating' => 3,
+ )
+ );
+
+ $this->assertSame( SuppressionReason::REVIEW_MISSING, $reason );
+ }
+
/**
* Creates a product review with a specific rating stored in comment meta.
*
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 6d27bcf8c7e..4cb665ff101 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\SuppressionReason;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
@@ -106,45 +107,70 @@ class NotificationTest extends WC_Unit_Test_Case {
}
/**
- * Default `should_send_to_user` should:
+ * @testdox should_send_to_user should answer from the reason, for code that loaded before an update.
+ */
+ public function test_should_send_to_user_answers_from_the_reason(): void {
+ $notification = $this->getMockBuilder( NewOrderNotification::class )
+ ->setConstructorArgs( array( 1 ) )
+ ->onlyMethods( array( 'to_payload', 'has_meta', 'write_meta', 'delete_meta' ) )
+ ->getMock();
+
+ $this->assertTrue( $notification->should_send_to_user( array( 'enabled' => true ) ) );
+ $this->assertFalse( $notification->should_send_to_user( array( 'enabled' => false ) ) );
+ }
+
+ /**
+ * Default `get_suppression_reason` should:
* - treat `null` (no stored value) as opt-in,
* - read `enabled` from the array shape today's storage produces,
- * - default to `true` when the array shape is missing the `enabled`
+ * - return null when the array shape is missing the `enabled`
* key (so newly-added notification types are opt-in by default),
* - and fall back to a defensive bool cast for unexpected scalars.
*
* @return array<string, array<mixed>>
*/
- public function provider_should_send_to_user_default(): array {
+ public function provider_suppression_reason_default(): array {
return array(
- 'null pref means opt-in by default' => array( null, true ),
- 'array with enabled true' => array( array( 'enabled' => true ), true ),
- 'array with enabled false' => array( array( 'enabled' => false ), false ),
- 'array missing enabled defaults to true' => array( array( 'min_value' => 500 ), true ),
- 'empty array defaults to true' => array( array(), true ),
- 'array with truthy enabled (1) is true' => array( array( 'enabled' => 1 ), true ),
- 'array with falsy enabled (0) is false' => array( array( 'enabled' => 0 ), false ),
- 'scalar bool true (defensive fallback)' => array( true, true ),
- 'scalar bool false (defensive fallback)' => array( false, false ),
- 'scalar truthy string (defensive fallback)' => array( '1', true ),
- 'scalar empty string (defensive fallback)' => array( '', false ),
+ 'null pref means opt-in by default' => array( null, null ),
+ 'array with enabled true' => array( array( 'enabled' => true ), null ),
+ 'array with enabled false' => array( array( 'enabled' => false ), SuppressionReason::NOTIFICATIONS_OFF ),
+ 'array missing enabled defaults to true' => array( array( 'min_value' => 500 ), null ),
+ 'empty array defaults to true' => array( array(), null ),
+ 'array with truthy enabled (1) is true' => array( array( 'enabled' => 1 ), null ),
+ 'array with falsy enabled (0) is false' => array( array( 'enabled' => 0 ), SuppressionReason::NOTIFICATIONS_OFF ),
+ 'scalar bool true (defensive fallback)' => array( true, null ),
+ 'scalar bool false (defensive fallback)' => array( false, SuppressionReason::NOTIFICATIONS_OFF ),
+ 'scalar truthy string (defensive fallback)' => array( '1', null ),
+ 'scalar empty string (defensive fallback)' => array( '', SuppressionReason::NOTIFICATIONS_OFF ),
);
}
/**
- * @testdox Default should_send_to_user with $_dataName returns $expected.
- * @dataProvider provider_should_send_to_user_default
+ * @testdox Default get_suppression_reason with $_dataName returns $expected.
+ * @dataProvider provider_suppression_reason_default
*
- * @param mixed $pref_value The stored preference value.
- * @param bool $expected The expected decision.
+ * @param mixed $pref_value The stored preference value.
+ * @param string|null $expected The expected reason, or null to send.
+ */
+ public function test_suppression_reason_default_behavior( $pref_value, ?string $expected ): void {
+ $notification = $this->getMockBuilder( NewOrderNotification::class )
+ ->setConstructorArgs( array( 1 ) )
+ ->onlyMethods( array( 'to_payload', 'has_meta', 'write_meta', 'delete_meta' ) )
+ ->getMock();
+
+ $this->assertSame( $expected, $notification->get_suppression_reason( $pref_value ) );
+ }
+
+ /**
+ * @testdox Default get_suppression_reason reports the type as disabled.
*/
- public function test_should_send_to_user_default_behavior( $pref_value, bool $expected ): void {
+ public function test_get_suppression_reason_default_is_type_disabled(): void {
$notification = $this->getMockBuilder( NewOrderNotification::class )
->setConstructorArgs( array( 1 ) )
->onlyMethods( array( 'to_payload', 'has_meta', 'write_meta', 'delete_meta' ) )
->getMock();
- $this->assertSame( $expected, $notification->should_send_to_user( $pref_value ) );
+ $this->assertSame( SuppressionReason::NOTIFICATIONS_OFF, $notification->get_suppression_reason( array( 'enabled' => false ) ) );
}
/**
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 4e8354a33fb..5bcb55a92ba 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/StockNotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/StockNotificationTest.php
@@ -4,6 +4,8 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\SuppressionReason;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use InvalidArgumentException;
@@ -312,13 +314,13 @@ class StockNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return true when enabled and event sub-flag is true.
+ * @testdox get_suppression_reason should return null when enabled and event sub-flag is true.
*/
- public function test_should_send_to_user_when_enabled_and_sub_flag_true(): void {
+ public function test_suppression_reason_when_enabled_and_sub_flag_true(): void {
$notification = new StockNotification( 1, StockNotification::EVENT_LOW_STOCK );
- $this->assertTrue(
- $notification->should_send_to_user(
+ $this->assertNull(
+ $notification->get_suppression_reason(
array(
'enabled' => true,
'low_stock' => true,
@@ -328,13 +330,13 @@ class StockNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return false when enabled but event sub-flag is false.
+ * @testdox get_suppression_reason should return a reason 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(
+ $this->assertNotNull(
+ $notification->get_suppression_reason(
array(
'enabled' => true,
'on_backorder' => false,
@@ -344,13 +346,13 @@ class StockNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should return false when notification is disabled regardless of sub-flags.
+ * @testdox get_suppression_reason should return a reason 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(
+ $this->assertNotNull(
+ $notification->get_suppression_reason(
array(
'enabled' => false,
'low_stock' => true,
@@ -360,23 +362,55 @@ class StockNotificationTest extends WC_Unit_Test_Case {
}
/**
- * @testdox should_send_to_user should default to true when event sub-flag key is missing.
+ * @testdox get_suppression_reason should default to null when event sub-flag key is missing.
*/
- public function test_should_send_to_user_when_sub_flag_missing(): void {
+ public function test_suppression_reason_when_sub_flag_missing(): void {
$notification = new StockNotification( 1, StockNotification::EVENT_LOW_STOCK );
- $this->assertTrue(
- $notification->should_send_to_user( array( 'enabled' => true ) )
+ $this->assertNull(
+ $notification->get_suppression_reason( array( 'enabled' => true ) )
);
}
/**
- * @testdox should_send_to_user should return true when pref_value is null.
+ * @testdox get_suppression_reason should return null when pref_value is null.
*/
- public function test_should_send_to_user_when_pref_null(): void {
+ public function test_suppression_reason_when_pref_null(): void {
$notification = new StockNotification( 1 );
- $this->assertTrue( $notification->should_send_to_user( null ) );
+ $this->assertNull( $notification->get_suppression_reason( null ) );
+ }
+
+ /**
+ * @testdox get_suppression_reason should name the type toggle when it is off, before the event flag.
+ */
+ public function test_get_suppression_reason_type_disabled_wins(): void {
+ $notification = new StockNotification( 1, StockNotification::EVENT_LOW_STOCK );
+
+ $reason = $notification->get_suppression_reason(
+ array(
+ 'enabled' => false,
+ 'low_stock' => false,
+ )
+ );
+
+ $this->assertSame( SuppressionReason::NOTIFICATIONS_OFF, $reason );
+ }
+
+ /**
+ * @testdox get_suppression_reason should name the event flag when only that is off.
+ */
+ public function test_get_suppression_reason_stock_alert_off(): void {
+ $notification = new StockNotification( 1, StockNotification::EVENT_LOW_STOCK );
+
+ $reason = $notification->get_suppression_reason(
+ array(
+ 'enabled' => true,
+ 'low_stock' => false,
+ )
+ );
+
+ $this->assertSame( SuppressionReason::STOCK_ALERT_OFF, $reason );
}
/**
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 dbed4d3da71..5a664ffc202 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
@@ -8,6 +8,8 @@ use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataS
use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\WpcomNotificationDispatcher;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\SendOutcome;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\SuppressionReason;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
@@ -16,6 +18,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationPreferencesService;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationRetryHandler;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
@@ -65,6 +68,13 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
*/
private $retry_handler;
+ /**
+ * Mock step logger.
+ *
+ * @var NotificationStepLogger
+ */
+ private $step_logger;
+
/**
* A test order ID.
*
@@ -82,10 +92,11 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
$this->data_store = $this->createMock( PushTokensDataStore::class );
$this->preferences_service = $this->createMock( NotificationPreferencesService::class );
$this->retry_handler = $this->createMock( NotificationRetryHandler::class );
+ $this->step_logger = $this->createMock( NotificationStepLogger::class );
$this->order_id = wc_create_order( array( 'status' => 'processing' ) )->get_id();
$this->sut = new NotificationProcessor();
- $this->sut->init( $this->dispatcher, $this->data_store, $this->preferences_service, $this->retry_handler );
+ $this->sut->init( $this->dispatcher, $this->data_store, $this->preferences_service, $this->retry_handler, $this->step_logger );
// By default every user has every notification type enabled, so existing
// tests behave as before. Per-user/per-type filtering is exercised in
@@ -314,7 +325,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
$this->dispatcher->expects( $this->never() )->method( 'dispatch' );
$sut = new NotificationProcessor();
- $sut->init( $this->dispatcher, $data_store, $this->preferences_service, $this->retry_handler );
+ $sut->init( $this->dispatcher, $data_store, $this->preferences_service, $this->retry_handler, $this->step_logger );
$notification = new NewOrderNotification( $this->order_id );
$result = $sut->process( $notification );
@@ -343,7 +354,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
$this->dispatcher->expects( $this->never() )->method( 'dispatch' );
$sut = new NotificationProcessor();
- $sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler );
+ $sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler, $this->step_logger );
$notification = new NewOrderNotification( $this->order_id );
$result = $sut->process( $notification );
@@ -414,7 +425,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
);
$sut = new NotificationProcessor();
- $sut->init( $this->dispatcher, $data_store, $preferences_service, $this->retry_handler );
+ $sut->init( $this->dispatcher, $data_store, $preferences_service, $this->retry_handler, $this->step_logger );
$notification = new NewOrderNotification( $this->order_id );
$result = $sut->process( $notification );
@@ -445,7 +456,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
);
$sut = new NotificationProcessor();
- $sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler );
+ $sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler, $this->step_logger );
// store_order is enabled — should dispatch.
$order_notification = new NewOrderNotification( $this->order_id );
@@ -525,14 +536,14 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
);
$sut = new NotificationProcessor();
- $sut->init( $this->dispatcher, $data_store, $preferences_service, $this->retry_handler );
+ $sut->init( $this->dispatcher, $data_store, $preferences_service, $this->retry_handler, $this->step_logger );
$this->assertTrue( $sut->process( new NewOrderNotification( $this->order_id ) ) );
}
/**
* Locks the delegation contract: the processor must consult
- * {@see Notification::should_send_to_user()} for the per-user decision and
+ * {@see Notification::get_suppression_reason()} for the per-user decision and
* pass it the raw stored preference value (so parametrized prefs like
* `['enabled' => true, 'min_value' => 500]` reach the subclass intact
* once the storage layer is widened to support them).
@@ -560,21 +571,21 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
'write_meta',
'read_meta',
'delete_meta',
- 'should_send_to_user',
+ 'get_suppression_reason',
)
)
->getMock();
$notification->method( 'get_type' )->willReturn( 'store_order' );
$notification->method( 'has_meta' )->willReturn( false );
$notification->expects( $this->once() )
- ->method( 'should_send_to_user' )
+ ->method( 'get_suppression_reason' )
->with( $this->equalTo( $pref_value ) )
- ->willReturn( false );
+ ->willReturn( SuppressionReason::NOTIFICATIONS_OFF );
$this->dispatcher->expects( $this->never() )->method( 'dispatch' );
$sut = new NotificationProcessor();
- $sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler );
+ $sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler, $this->step_logger );
$result = $sut->process( $notification );
@@ -775,6 +786,329 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Should log a no_tokens outcome with the counts when the store has no eligible tokens.
+ */
+ public function test_process_logs_no_tokens(): void {
+ $data_store = $this->createMock( PushTokensDataStore::class );
+ $data_store->method( 'get_tokens_for_roles' )->willReturn( array() );
+ $data_store->method( 'count_tokens' )->willReturn( 2 );
+
+ $logger = $this->create_active_step_logger();
+ $logger->expects( $this->once() )
+ ->method( 'log_notification_step' )
+ ->with(
+ $this->anything(),
+ 'recipients',
+ 'none',
+ $this->callback(
+ function ( array $context ) {
+ return 2 === $context['tokens_total']
+ && 0 === $context['tokens_eligible']
+ && 0 === $context['recipients']
+ && 0 === $context['held_back']
+ && 0 === $context['attempt']
+ && false === $context['is_retry'];
+ }
+ )
+ );
+ $logger->expects( $this->never() )->method( 'log_suppressed_step' );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $data_store, $this->preferences_service, $this->retry_handler, $logger );
+
+ $sut->process( new NewOrderNotification( $this->order_id ) );
+ }
+
+ /**
+ * @testdox Should name the excluded tokens grouped by reason and log an all_excluded outcome when nobody wants it.
+ */
+ public function test_process_logs_excluded_tokens_with_reasons(): void {
+ $data_store = $this->createMock( PushTokensDataStore::class );
+ $data_store->method( 'get_tokens_for_roles' )->willReturn(
+ array(
+ $this->create_token( 11, 1 ),
+ $this->create_token( 12, 1 ),
+ $this->create_token( 13, 2 ),
+ )
+ );
+ $data_store->method( 'count_tokens' )->willReturn( 3 );
+
+ $preferences_service = $this->createMock( NotificationPreferencesService::class );
+ $preferences_service->method( 'get_preferences' )->willReturn(
+ array( 'store_order' => array( 'enabled' => false ) )
+ );
+
+ $logger = $this->create_active_step_logger();
+ $logger->expects( $this->once() )
+ ->method( 'log_notification_step' )
+ ->with(
+ $this->anything(),
+ 'recipients',
+ 'all_excluded',
+ $this->callback(
+ function ( array $context ) {
+ return 3 === $context['held_back']
+ && 0 === $context['recipients']
+ && array( 'notifications_off' => 3 ) === $context['held_back_reasons'];
+ }
+ )
+ );
+ $logger->expects( $this->once() )
+ ->method( 'log_suppressed_step' )
+ ->with(
+ $this->anything(),
+ 'token_excluded',
+ 'notifications_off',
+ $this->callback(
+ fn( array $context ) => array( 'notifications_off' => array( 11, 12, 13 ) ) === $context['excluded_tokens']
+ )
+ );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $data_store, $preferences_service, $this->retry_handler, $logger );
+
+ $this->assertTrue( $sut->process( new NewOrderNotification( $this->order_id ) ) );
+ }
+
+ /**
+ * @testdox Should log recipients resolved with the recipient token IDs and no line per recipient.
+ */
+ public function test_process_logs_recipients_resolved(): void {
+ $data_store = $this->createMock( PushTokensDataStore::class );
+ $data_store->method( 'get_tokens_for_roles' )->willReturn(
+ array( $this->create_token( 11, 1 ), $this->create_token( 12, 2 ) )
+ );
+ $data_store->method( 'count_tokens' )->willReturn( 2 );
+ $this->dispatcher->method( 'dispatch' )->willReturn( array( 'success' => true ) );
+
+ $logger = $this->create_active_step_logger();
+ $logger->expects( $this->once() )
+ ->method( 'log_notification_step' )
+ ->with(
+ $this->anything(),
+ 'recipients',
+ 'resolved',
+ $this->callback(
+ function ( array $context ) {
+ return 2 === $context['recipients']
+ && array( 11, 12 ) === $context['token_ids'];
+ }
+ )
+ );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $data_store, $this->preferences_service, $this->retry_handler, $logger );
+
+ $this->assertTrue( $sut->process( new NewOrderNotification( $this->order_id ) ) );
+ }
+
+ /**
+ * @testdox Should name the tokens WPCOM refused on one dispatched line.
+ */
+ public function test_process_names_the_refused_tokens(): void {
+ $data_store = $this->createMock( PushTokensDataStore::class );
+ $data_store->method( 'get_tokens_for_roles' )->willReturn(
+ array( $this->create_token( 11, 1 ), $this->create_token( 12, 2 ) )
+ );
+ $data_store->method( 'count_tokens' )->willReturn( 2 );
+ $this->dispatcher->method( 'dispatch' )->willReturn(
+ array(
+ 'success' => false,
+ 'retry_after' => null,
+ 'outcome' => SendOutcome::REJECTED_INVALID_TOKEN,
+ 'invalid_tokens' => array( 'token-12' ),
+ )
+ );
+
+ $logged = array();
+ $logger = $this->create_active_step_logger();
+ $logger->method( 'log_notification_step' )
+ ->willReturnCallback(
+ function ( $notification, string $step, string $outcome, array $context = array() ) use ( &$logged ) {
+ $logged[] = array( $step, $outcome, $context );
+ }
+ );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $data_store, $this->preferences_service, $this->retry_handler, $logger );
+
+ $this->assertFalse( $sut->process( new NewOrderNotification( $this->order_id ) ) );
+
+ $dispatched = array_values(
+ array_filter( $logged, fn( array $line ) => 'dispatched' === $line[0] )
+ );
+
+ $this->assertCount( 1, $dispatched, 'Exactly one dispatched line should name the refused tokens.' );
+ $this->assertSame( 'invalid_token', $dispatched[0][1] );
+ $this->assertSame( array( 12 ), $dispatched[0][2]['invalid_token_ids'] );
+ }
+
+ /**
+ * @testdox Should log the fallback firing before processing.
+ */
+ public function test_handle_safety_net_logs_that_it_fired(): void {
+ $this->data_store->method( 'get_tokens_for_roles' )->willReturn( array() );
+
+ $logger = $this->create_active_step_logger();
+ $logger->expects( $this->atLeastOnce() )
+ ->method( 'log_notification_step' )
+ ->withConsecutive( array( $this->anything(), 'fallback', 'fired' ) );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $this->data_store, $this->preferences_service, $this->retry_handler, $logger );
+
+ $sut->handle_safety_net( 'store_order', $this->order_id );
+ }
+
+ /**
+ * @testdox Should record the full recipient count but cap the token IDs it names.
+ */
+ public function test_process_caps_the_recipient_ids_it_names(): void {
+ $tokens = array();
+ for ( $i = 1; $i <= NotificationProcessor::RECIPIENT_ID_CAP + 5; $i++ ) {
+ $tokens[] = $this->create_token( 100 + $i, 1 );
+ }
+
+ $data_store = $this->createMock( PushTokensDataStore::class );
+ $data_store->method( 'get_tokens_for_roles' )->willReturn( $tokens );
+ $data_store->method( 'count_tokens' )->willReturn( count( $tokens ) );
+ $this->dispatcher->method( 'dispatch' )->willReturn( array( 'success' => true ) );
+
+ $logger = $this->create_active_step_logger();
+ $logger->expects( $this->once() )
+ ->method( 'log_notification_step' )
+ ->with(
+ $this->anything(),
+ 'recipients',
+ 'resolved',
+ $this->callback(
+ function ( array $context ) {
+ return count( $context['token_ids'] ) === NotificationProcessor::RECIPIENT_ID_CAP
+ && NotificationProcessor::RECIPIENT_ID_CAP + 5 === $context['recipients'];
+ }
+ )
+ );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $data_store, $this->preferences_service, $this->retry_handler, $logger );
+
+ $sut->process( new NewOrderNotification( $this->order_id ) );
+ }
+
+ /**
+ * @testdox Should not count tokens or write recipient lines when step logging is inactive.
+ */
+ public function test_process_skips_recipient_logging_when_inactive(): void {
+ $data_store = $this->createMock( PushTokensDataStore::class );
+ $data_store->method( 'get_tokens_for_roles' )->willReturn( array( $this->create_token( 11, 1 ) ) );
+ $data_store->expects( $this->never() )->method( 'count_tokens' );
+ $this->dispatcher->method( 'dispatch' )->willReturn( array( 'success' => true ) );
+
+ $logger = $this->createMock( NotificationStepLogger::class );
+ $logger->method( 'is_active' )->willReturn( false );
+ $logger->expects( $this->never() )->method( 'log_notification_step' );
+ $logger->expects( $this->never() )->method( 'log_suppressed_step' );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $data_store, $this->preferences_service, $this->retry_handler, $logger );
+
+ $this->assertTrue( $sut->process( new NewOrderNotification( $this->order_id ) ) );
+ }
+
+ /**
+ * @testdox Should write excluded tokens to the store-wide suppressed source, naming them on one line.
+ */
+ public function test_process_writes_excluded_tokens_to_the_suppressed_source(): void {
+ $data_store = $this->createMock( PushTokensDataStore::class );
+ $data_store->method( 'get_tokens_for_roles' )->willReturn( array( $this->create_token( 11, 1 ) ) );
+ $data_store->method( 'count_tokens' )->willReturn( 1 );
+
+ $preferences_service = $this->createMock( NotificationPreferencesService::class );
+ $preferences_service->method( 'get_preferences' )->willReturn(
+ array( 'store_order' => array( 'enabled' => false ) )
+ );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $data_store, $preferences_service, $this->retry_handler, $this->create_real_step_logger() );
+
+ $this->assertTrue( $sut->process( new NewOrderNotification( $this->order_id ) ) );
+
+ $this->assertLogged(
+ 'info',
+ 'Token excluded: notifications off',
+ array(
+ 'source' => NotificationStepLogger::SUPPRESSED_SOURCE,
+ 'excluded_tokens' => array( 'notifications_off' => array( 11 ) ),
+ )
+ );
+ }
+
+ /**
+ * @testdox Should log a skipped step when the notification was already sent or already claimed.
+ */
+ public function test_process_logs_already_sent_and_already_claimed(): void {
+ $logger = $this->create_active_step_logger();
+ $logger->expects( $this->exactly( 2 ) )
+ ->method( 'log_notification_step' )
+ ->withConsecutive(
+ array( $this->anything(), 'skipped', 'already_claimed' ),
+ array( $this->anything(), 'skipped', 'already_sent' )
+ );
+
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $this->data_store, $this->preferences_service, $this->retry_handler, $logger );
+
+ $notification = new NewOrderNotification( $this->order_id );
+ $notification->write_meta( NotificationProcessor::CLAIMED_META_KEY );
+ $sut->process( $notification );
+
+ $notification->write_meta( NotificationProcessor::SENT_META_KEY );
+ $sut->process( $notification );
+ }
+
+ /**
+ * Creates a token with an ID, as the data store returns them.
+ *
+ * @param int $id The token post ID.
+ * @param int $user_id The owning user.
+ * @return PushToken
+ */
+ private function create_token( int $id, int $user_id ): PushToken {
+ return new PushToken(
+ array(
+ 'id' => $id,
+ 'user_id' => $user_id,
+ 'token' => 'token-' . $id,
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_locale' => 'en_US',
+ 'device_uuid' => 'uuid-' . $id,
+ )
+ );
+ }
+
+ /**
+ * Creates a step logger mock that reports itself active.
+ *
+ * @return NotificationStepLogger|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private function create_active_step_logger() {
+ $logger = $this->createMock( NotificationStepLogger::class );
+ $logger->method( 'is_active' )->willReturn( true );
+ return $logger;
+ }
+
+ /**
+ * Creates a real step logger on a store that has registered tokens, so its
+ * lines reach the spy logger.
+ *
+ * @return NotificationStepLogger
+ */
+ private function create_real_step_logger(): NotificationStepLogger {
+ return new NotificationStepLogger();
+ }
+
/**
* Schedules a safety-net action through the real
* {@see PendingNotificationStore::schedule_safety_net()} so tests exercise
@@ -785,7 +1119,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
*/
private function schedule_safety_net( Notification $notification ): void {
$store = new PendingNotificationStore();
- $store->init( $this->createMock( InternalNotificationDispatcher::class ), $this->create_data_store_with_tokens( true ) );
+ $store->init( $this->createMock( InternalNotificationDispatcher::class ), $this->create_data_store_with_tokens( true ), $this->createMock( NotificationStepLogger::class ) );
$method = new \ReflectionMethod( PendingNotificationStore::class, 'schedule_safety_net' );
$method->setAccessible( true );
@@ -830,9 +1164,21 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
public function test_handle_safety_net_logs_error_for_unknown_type(): void {
$this->dispatcher->expects( $this->never() )->method( 'dispatch' );
- $this->sut->handle_safety_net( 'unknown_type', 1 );
+ $sut = new NotificationProcessor();
+ $sut->init( $this->dispatcher, $this->data_store, $this->preferences_service, $this->retry_handler, $this->create_real_step_logger() );
+
+ $sut->handle_safety_net( 'unknown_type', 1 );
- $this->assertLogged( 'error', 'Safety net failed:', array( 'source' => PushNotifications::FEATURE_NAME ) );
+ $this->assertLogged(
+ 'error',
+ 'Fallback failed:',
+ array(
+ 'source' => PushNotifications::FEATURE_NAME,
+ 'step' => 'fallback',
+ 'outcome' => 'invalid_notification',
+ 'type' => 'unknown_type',
+ )
+ );
}
/**
@@ -857,7 +1203,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
$this->dispatcher->expects( $this->never() )->method( 'dispatch' );
$sut = new NotificationProcessor();
- $sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler );
+ $sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler, $this->step_logger );
$notification = new NewOrderNotification( $order->get_id() );
$result = $sut->process( $notification );
@@ -893,7 +1239,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
$this->dispatcher->expects( $this->never() )->method( 'dispatch' );
$sut = new NotificationProcessor();
- $sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler );
+ $sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler, $this->step_logger );
$notification = new NewReviewNotification( $comment_id );
$result = $sut->process( $notification );
@@ -1079,7 +1425,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
$this->dispatcher->expects( $this->never() )->method( 'dispatch' );
$sut = new NotificationProcessor();
- $sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler );
+ $sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler, $this->step_logger );
$notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
$result = $sut->process( $notification );
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 c280a15420f..169f3a2e549 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationRetryHandlerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationRetryHandlerTest.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationPreferencesService;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationRetryHandler;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
use stdClass;
use WC_Helper_Product;
@@ -45,7 +46,10 @@ class NotificationRetryHandlerTest extends WC_Unit_Test_Case {
public function setUp(): void {
parent::setUp();
- $this->sut = new NotificationRetryHandler();
+ $step_logger = new NotificationStepLogger();
+
+ $this->sut = new NotificationRetryHandler();
+ $this->sut->init( $step_logger );
$this->order_id = wc_create_order( array( 'status' => 'processing' ) )->get_id();
}
@@ -88,6 +92,17 @@ class NotificationRetryHandlerTest extends WC_Unit_Test_Case {
$this->assertNotFalse( $scheduled, 'A retry action should be scheduled.' );
$this->assertEqualsWithDelta( time() + $expected_delay, $scheduled, 2, 'Retry should be scheduled with the correct backoff delay.' );
+ $this->assertLogged(
+ 'info',
+ 'Retry: scheduled',
+ array(
+ 'source' => 'push-notifications-store-order',
+ 'step' => 'retry',
+ 'outcome' => 'scheduled',
+ 'next_attempt' => $expected_next,
+ 'delay' => $expected_delay,
+ )
+ );
}
/**
@@ -131,7 +146,19 @@ class NotificationRetryHandlerTest extends WC_Unit_Test_Case {
);
$this->assertFalse( $scheduled, 'No retry should be scheduled after max retries.' );
- $this->assertLogged( 'error', 'permanently failed after 5 attempts', array( 'source' => PushNotifications::FEATURE_NAME ) );
+ $this->assertLogged(
+ 'error',
+ 'permanently failed after 5 attempts',
+ array(
+ 'source' => PushNotifications::FEATURE_NAME,
+ 'identifier' => $notification->get_identifier(),
+ 'step' => 'retry',
+ 'outcome' => 'exhausted',
+ 'attempt' => NotificationRetryHandler::MAX_RETRIES,
+ 'resource_id' => $this->order_id,
+ )
+ );
+ $this->assertLogged( 'info', 'Retry: exhausted', array( 'source' => 'push-notifications-store-order' ) );
}
/**
@@ -235,7 +262,13 @@ class NotificationRetryHandlerTest extends WC_Unit_Test_Case {
);
$processor = new NotificationProcessor();
- $processor->init( $dispatcher, $data_store, $preferences_service, $retry_handler );
+ $processor->init(
+ $dispatcher,
+ $data_store,
+ $preferences_service,
+ $retry_handler,
+ $this->createMock( NotificationStepLogger::class )
+ );
wc_get_container()->replace( NotificationProcessor::class, $processor );
$this->sut->handle_retry( 'store_order', $this->order_id, 2 );
@@ -250,7 +283,17 @@ class NotificationRetryHandlerTest extends WC_Unit_Test_Case {
public function test_handle_retry_logs_error_for_unknown_type(): void {
$this->sut->handle_retry( 'unknown_type', 1, 1 );
- $this->assertLogged( 'error', 'Retry failed:', array( 'source' => PushNotifications::FEATURE_NAME ) );
+ $this->assertLogged(
+ 'error',
+ 'Retry failed:',
+ array(
+ 'source' => PushNotifications::FEATURE_NAME,
+ 'step' => 'retry',
+ 'outcome' => 'invalid_notification',
+ 'type' => 'unknown_type',
+ 'attempt' => 1,
+ )
+ );
}
/**
@@ -484,7 +527,7 @@ class NotificationRetryHandlerTest extends WC_Unit_Test_Case {
);
$processor = new NotificationProcessor();
- $processor->init( $dispatcher, $data_store, $preferences_service, $this->createMock( NotificationRetryHandler::class ) );
+ $processor->init( $dispatcher, $data_store, $preferences_service, $this->createMock( NotificationRetryHandler::class ), $this->createMock( NotificationStepLogger::class ) );
wc_get_container()->replace( NotificationProcessor::class, $processor );
return $captured;
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationStepLoggerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationStepLoggerTest.php
index 752041d515c..77c69871d9f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationStepLoggerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationStepLoggerTest.php
@@ -52,7 +52,7 @@ class NotificationStepLoggerTest extends WC_Unit_Test_Case {
* @testdox Should write a notification step at info level to the type source with the identifying context.
*/
public function test_log_notification_step_writes_to_the_type_source(): void {
- $this->sut->log_notification_step( $this->create_order_mock( 42 ), 'cleared_to_send', 'ok', array( 'recipients' => 3 ) );
+ $this->sut->log_notification_step( $this->create_order_mock( 42 ), 'recipients', 'resolved', array( 'recipients' => 3 ) );
$this->assertCount( 1, $this->logger->info_calls );
@@ -61,28 +61,130 @@ class NotificationStepLoggerTest extends WC_Unit_Test_Case {
$this->assertSame( get_current_blog_id() . '_store_order_42', $context['identifier'] );
$this->assertSame( 'store_order', $context['type'] );
$this->assertSame( 42, $context['resource_id'] );
- $this->assertSame( 'cleared_to_send', $context['step'] );
- $this->assertSame( 'ok', $context['outcome'] );
+ $this->assertSame( 'recipients', $context['step'] );
+ $this->assertSame( 'resolved', $context['outcome'] );
$this->assertSame( 3, $context['recipients'] );
$this->assertFalse( $context['remote-logging'] );
- $this->assertSame( 'Cleared to send: ok', $this->logger->info_calls[0]['message'] );
+ $this->assertSame( 'Recipients: resolved', $this->logger->info_calls[0]['message'] );
}
/**
- * @testdox Should write a token step to that token's source with the token and user IDs.
+ * A caller can reach a logged exception message, and the handler writes the
+ * message into the line unencoded, so a newline would let it forge a line.
+ *
+ * @testdox Should strip control characters from a logged message so it cannot add a line.
+ */
+ public function test_log_failure_strips_control_characters_from_the_message(): void {
+ $this->sut->log_failure(
+ $this->create_order_mock( 42 ),
+ 'processing',
+ 'exception',
+ 'error',
+ "Unknown notification type: evil\n2026-10-02T00:00:00+00:00 INFO Dispatched: accepted"
+ );
+
+ $this->assertStringNotContainsString( "\n", $this->logger->log_calls[0]['message'] );
+ }
+
+ /**
+ * A caller controls the value an exception names, so a message can hold any
+ * bytes at all.
+ *
+ * @testdox Should still write a message whose bytes are not valid UTF-8.
+ */
+ public function test_log_failure_keeps_a_message_with_invalid_utf8(): void {
+ $this->sut->log_failure(
+ $this->create_order_mock( 42 ),
+ 'processing',
+ 'exception',
+ 'error',
+ "Unknown notification type: \x80\nforged line"
+ );
+
+ $message = $this->logger->log_calls[0]['message'];
+
+ $this->assertStringContainsString( 'Unknown notification type:', $message );
+ $this->assertStringNotContainsString( "\n", $message );
+ }
+
+ /**
+ * @testdox Should cap a logged message rather than write whatever an exception carried.
+ */
+ public function test_log_failure_caps_the_message_length(): void {
+ $this->sut->log_failure( $this->create_order_mock( 42 ), 'processing', 'exception', 'error', str_repeat( 'a', 5000 ) );
+
+ $this->assertStringEndsWith( '... (truncated)', $this->logger->log_calls[0]['message'] );
+ $this->assertLessThan( 5000, strlen( $this->logger->log_calls[0]['message'] ) );
+ }
+
+ /**
+ * @testdox Should cap a context string so a caller-supplied value cannot fill the line.
+ */
+ public function test_context_strings_are_capped(): void {
+ $this->sut->log_notification_step( $this->create_order_mock( 42 ), 'recipients', 'resolved', array( 'note' => str_repeat( 'b', 1000 ) ) );
+
+ $this->assertStringEndsWith( '... (truncated)', $this->logger->info_calls[0]['context']['note'] );
+ }
+
+ /**
+ * @testdox Should cap a context array and say how many entries it left out.
+ */
+ public function test_context_arrays_are_capped_and_count_what_was_omitted(): void {
+ $this->sut->log_notification_step(
+ $this->create_order_mock( 42 ),
+ 'recipients',
+ 'resolved',
+ array( 'token_ids' => range( 1, 450 ) )
+ );
+
+ $context = $this->logger->info_calls[0]['context'];
+
+ $this->assertCount( NotificationStepLogger::MAX_ARRAY_ITEMS, $context['token_ids'] );
+ $this->assertSame( 350, $context['token_ids_omitted'] );
+ }
+
+ /**
+ * @testdox Should cap each list of a grouped map and count what each one left out.
*/
- public function test_log_token_step_writes_to_the_token_source(): void {
- $this->sut->log_token_step( $this->create_order_mock( 42 ), 4412, 7, 'held_back', 'type_disabled' );
+ public function test_grouped_context_arrays_are_capped_per_group(): void {
+ $this->sut->log_suppressed_step(
+ $this->create_order_mock( 42 ),
+ 'token_excluded',
+ 'various',
+ array(
+ 'excluded_tokens' => array(
+ 'notifications_off' => range( 1, 250 ),
+ 'no_account' => range( 1, 5 ),
+ ),
+ )
+ );
+
+ $context = $this->logger->info_calls[0]['context'];
+
+ $this->assertCount( NotificationStepLogger::MAX_ARRAY_ITEMS, $context['excluded_tokens']['notifications_off'] );
+ $this->assertCount( 5, $context['excluded_tokens']['no_account'] );
+ $this->assertSame( array( 'notifications_off' => 150 ), $context['excluded_tokens_omitted'] );
+ }
+
+ /**
+ * @testdox Should write an excluded-token step to the store-wide suppressed source.
+ */
+ public function test_log_suppressed_step_writes_to_the_suppressed_source(): void {
+ $this->sut->log_suppressed_step(
+ $this->create_order_mock( 42 ),
+ 'token_excluded',
+ 'notifications_off',
+ array( 'excluded_tokens' => array( 'notifications_off' => array( 4412 ) ) )
+ );
$this->assertCount( 1, $this->logger->info_calls );
$context = $this->logger->info_calls[0]['context'];
- $this->assertSame( 'push-token-4412', $context['source'] );
- $this->assertSame( 4412, $context['token_id'] );
- $this->assertSame( 7, $context['user_id'] );
+ $this->assertSame( NotificationStepLogger::SUPPRESSED_SOURCE, $context['source'] );
+ $this->assertSame( array( 'notifications_off' => array( 4412 ) ), $context['excluded_tokens'] );
$this->assertSame( get_current_blog_id() . '_store_order_42', $context['identifier'] );
- $this->assertSame( 'held_back', $context['step'] );
- $this->assertSame( 'type_disabled', $context['outcome'] );
+ $this->assertSame( 'token_excluded', $context['step'] );
+ $this->assertSame( 'notifications_off', $context['outcome'] );
}
/**
@@ -224,7 +326,7 @@ class NotificationStepLoggerTest extends WC_Unit_Test_Case {
public function test_log_failure_writes_an_error_line_and_a_step_line(): void {
$this->sut->log_failure(
$this->create_order_mock( 42 ),
- 'send',
+ 'dispatched',
'request_failed',
'error',
'Push notification request failed.',
@@ -241,13 +343,13 @@ class NotificationStepLoggerTest extends WC_Unit_Test_Case {
$this->assertSame( get_current_blog_id() . '_store_order_42', $error['context']['identifier'] );
$this->assertSame( 'store_order', $error['context']['type'] );
$this->assertSame( 42, $error['context']['resource_id'] );
- $this->assertSame( 'send', $error['context']['step'] );
+ $this->assertSame( 'dispatched', $error['context']['step'] );
$this->assertSame( 'request_failed', $error['context']['outcome'] );
$this->assertSame( 500, $error['context']['http_status'] );
$this->assertFalse( $error['context']['remote-logging'] );
$step = $this->logger->info_calls[0];
- $this->assertSame( 'Send: request failed', $step['message'] );
+ $this->assertSame( 'Dispatched: request failed', $step['message'] );
$this->assertSame( 'push-notifications-store-order', $step['context']['source'] );
$this->assertSame( 500, $step['context']['http_status'] );
}
@@ -256,7 +358,7 @@ class NotificationStepLoggerTest extends WC_Unit_Test_Case {
* @testdox Should write the failure's error line at the level the caller asked for.
*/
public function test_log_failure_uses_the_given_level(): void {
- $this->sut->log_failure( $this->create_order_mock( 42 ), 'dispatched', 'request_failed', 'warning', 'Loopback request failed.' );
+ $this->sut->log_failure( $this->create_order_mock( 42 ), 'loopback_requested', 'request_failed', 'warning', 'Loopback request failed.' );
$this->assertSame( 'warning', $this->logger->log_calls[0]['level'] );
}
@@ -271,7 +373,7 @@ class NotificationStepLoggerTest extends WC_Unit_Test_Case {
->getMock();
$notification->method( 'get_identifier' )->willThrowException( new \RuntimeException( 'getter exploded' ) );
- $this->sut->log_failure( $notification, 'send', 'request_failed', 'error', 'Push notification request failed.' );
+ $this->sut->log_failure( $notification, 'dispatched', 'request_failed', 'error', 'Push notification request failed.' );
$this->assertCount( 0, $this->logger->info_calls );
$this->assertCount( 1, $this->logger->log_calls );
@@ -287,7 +389,7 @@ class NotificationStepLoggerTest extends WC_Unit_Test_Case {
* @testdox Should record an unattributed failure under the module source only, with no step line.
*/
public function test_log_unattributed_failure_writes_no_step_line(): void {
- $this->sut->log_unattributed_failure( 'received', 'auth_failed', 'warning', 'Loopback request refused.', array( 'reason' => 'token_invalid' ) );
+ $this->sut->log_unattributed_failure( 'loopback_started', 'auth_failed', 'warning', 'Loopback request refused.', array( 'reason' => 'token_invalid' ) );
$this->assertCount( 0, $this->logger->info_calls );
$this->assertCount( 1, $this->logger->log_calls );
@@ -295,7 +397,7 @@ class NotificationStepLoggerTest extends WC_Unit_Test_Case {
$error = $this->logger->log_calls[0];
$this->assertSame( 'warning', $error['level'] );
$this->assertSame( 'push_notifications', $error['context']['source'] );
- $this->assertSame( 'received', $error['context']['step'] );
+ $this->assertSame( 'loopback_started', $error['context']['step'] );
$this->assertSame( 'auth_failed', $error['context']['outcome'] );
$this->assertSame( 'token_invalid', $error['context']['reason'] );
$this->assertArrayNotHasKey( 'identifier', $error['context'] );
@@ -307,8 +409,8 @@ class NotificationStepLoggerTest extends WC_Unit_Test_Case {
public function test_the_filter_does_not_suppress_failure_lines(): void {
add_filter( 'woocommerce_push_notification_step_logging_enabled', '__return_false' );
- $this->sut->log_failure( $this->create_order_mock( 42 ), 'send', 'request_failed', 'error', 'Push notification request failed.' );
- $this->sut->log_unattributed_failure( 'received', 'auth_failed', 'warning', 'Loopback request refused.' );
+ $this->sut->log_failure( $this->create_order_mock( 42 ), 'dispatched', 'request_failed', 'error', 'Push notification request failed.' );
+ $this->sut->log_unattributed_failure( 'loopback_started', 'auth_failed', 'warning', 'Loopback request refused.' );
$this->assertCount( 0, $this->logger->info_calls );
$this->assertCount( 2, $this->logger->log_calls );
@@ -343,7 +445,7 @@ class NotificationStepLoggerTest extends WC_Unit_Test_Case {
20
);
- $this->sut->log_unattributed_failure( 'received', 'auth_failed', 'warning', 'Loopback request refused.' );
+ $this->sut->log_unattributed_failure( 'loopback_started', 'auth_failed', 'warning', 'Loopback request refused.' );
$this->assertTrue( true, 'No exception reached the caller.' );
}
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 53994eeaa92..35aa8863b10 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNot
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
use WC_Helper_Order;
use WC_Unit_Test_Case;
@@ -37,7 +38,7 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
$dispatcher = $this->createMock( InternalNotificationDispatcher::class );
$this->store = new PendingNotificationStore();
- $this->store->init( $dispatcher, $this->create_data_store_with_tokens( true ) );
+ $this->store->init( $dispatcher, $this->create_data_store_with_tokens( true ), $this->createMock( NotificationStepLogger::class ) );
$this->store->register();
}
@@ -174,13 +175,91 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
$this->assertSame( 2, $this->store->count() );
}
+ /**
+ * Action Scheduler returns 0 rather than throwing when it cannot store the
+ * job, so a notification with no fallback would otherwise be logged as
+ * though one had been scheduled.
+ *
+ * @testdox Should log schedule_failed when Action Scheduler refuses the fallback job.
+ */
+ public function test_add_logs_a_failed_fallback_schedule(): void {
+ add_filter( 'pre_as_schedule_single_action', '__return_zero' );
+
+ $dispatcher = $this->createMock( InternalNotificationDispatcher::class );
+ $step_logger = $this->createMock( NotificationStepLogger::class );
+ $step_logger->expects( $this->once() )
+ ->method( 'log_notification_step' )
+ ->with( $this->anything(), 'triggered', 'queued', array( 'fallback' => 'schedule_failed' ) );
+ $store = new PendingNotificationStore();
+ $store->init( $dispatcher, $this->create_data_store_with_tokens( true ), $step_logger );
+ $store->register();
+
+ $store->add( $this->create_order_mock( 42 ) );
+
+ remove_filter( 'pre_as_schedule_single_action', '__return_zero' );
+ }
+
+ /**
+ * @testdox Should log the trigger as queued with its fallback, then as a duplicate on a repeat.
+ */
+ public function test_add_logs_queued_then_duplicate(): void {
+ $dispatcher = $this->createMock( InternalNotificationDispatcher::class );
+ $step_logger = $this->createMock( NotificationStepLogger::class );
+ $step_logger->expects( $this->exactly( 2 ) )
+ ->method( 'log_notification_step' )
+ ->withConsecutive(
+ array( $this->anything(), 'triggered', 'queued', array( 'fallback' => 'scheduled' ) ),
+ array( $this->anything(), 'triggered', 'duplicate_in_request' )
+ );
+ $store = new PendingNotificationStore();
+ $store->init( $dispatcher, $this->create_data_store_with_tokens( true ), $step_logger );
+ $store->register();
+
+ $store->add( $this->create_order_mock( 42 ) );
+ $store->add( $this->create_order_mock( 42 ) );
+ }
+
+ /**
+ * @testdox Should log the trigger as finding no tokens when none are registered.
+ */
+ public function test_add_logs_no_tokens(): void {
+ $dispatcher = $this->createMock( InternalNotificationDispatcher::class );
+ $step_logger = $this->createMock( NotificationStepLogger::class );
+ $step_logger->expects( $this->once() )
+ ->method( 'log_notification_step' )
+ ->with( $this->anything(), 'triggered', 'no_tokens' );
+ $store = new PendingNotificationStore();
+ $store->init( $dispatcher, $this->create_data_store_with_tokens( false ), $step_logger );
+ $store->register();
+
+ $store->add( $this->create_order_mock( 42 ) );
+ }
+
+ /**
+ * @testdox Should record that the store holds no tokens once per notification type, however many events fire.
+ */
+ public function test_add_logs_no_tokens_once_per_type_per_request(): void {
+ $dispatcher = $this->createMock( InternalNotificationDispatcher::class );
+ $step_logger = $this->createMock( NotificationStepLogger::class );
+ $step_logger->expects( $this->exactly( 2 ) )
+ ->method( 'log_notification_step' )
+ ->with( $this->anything(), 'triggered', 'no_tokens' );
+ $store = new PendingNotificationStore();
+ $store->init( $dispatcher, $this->create_data_store_with_tokens( false ), $step_logger );
+ $store->register();
+
+ $store->add( $this->create_stock_mock( 42, StockNotification::EVENT_LOW_STOCK ) );
+ $store->add( $this->create_stock_mock( 43, StockNotification::EVENT_LOW_STOCK ) );
+ $store->add( $this->create_order_mock( 1 ) );
+ }
+
/**
* @testdox Should not add notifications when store has not been registered.
*/
public function test_add_does_nothing_when_not_registered(): void {
$dispatcher = $this->createMock( InternalNotificationDispatcher::class );
$store = new PendingNotificationStore();
- $store->init( $dispatcher, $this->create_data_store_with_tokens( true ) );
+ $store->init( $dispatcher, $this->create_data_store_with_tokens( true ), $this->createMock( NotificationStepLogger::class ) );
$store->add( $this->create_order_mock( 42 ) );
@@ -193,7 +272,7 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
public function test_add_does_nothing_when_no_tokens_registered(): void {
$dispatcher = $this->createMock( InternalNotificationDispatcher::class );
$store = new PendingNotificationStore();
- $store->init( $dispatcher, $this->create_data_store_with_tokens( false ) );
+ $store->init( $dispatcher, $this->create_data_store_with_tokens( false ), $this->createMock( NotificationStepLogger::class ) );
$store->register();
$store->add( $this->create_order_mock( 42 ) );
@@ -209,7 +288,7 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
$dispatcher = $this->createMock( InternalNotificationDispatcher::class );
$store = new PendingNotificationStore();
- $store->init( $dispatcher, $this->create_data_store_with_tokens( false ) );
+ $store->init( $dispatcher, $this->create_data_store_with_tokens( false ), $this->createMock( NotificationStepLogger::class ) );
$store->register();
$store->add( $notification );
@@ -229,7 +308,7 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
public function test_add_does_not_register_shutdown_hook_when_no_tokens_registered(): void {
$dispatcher = $this->createMock( InternalNotificationDispatcher::class );
$store = new PendingNotificationStore();
- $store->init( $dispatcher, $this->create_data_store_with_tokens( false ) );
+ $store->init( $dispatcher, $this->create_data_store_with_tokens( false ), $this->createMock( NotificationStepLogger::class ) );
$store->register();
$store->add( $this->create_order_mock( 42 ) );
@@ -282,7 +361,7 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
->method( 'dispatch' );
$store = new PendingNotificationStore();
- $store->init( $dispatcher, $this->create_data_store_with_tokens( true ) );
+ $store->init( $dispatcher, $this->create_data_store_with_tokens( true ), $this->createMock( NotificationStepLogger::class ) );
$store->register();
$store->add( $this->create_order_mock( 1 ) );
@@ -306,7 +385,7 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
);
$store = new PendingNotificationStore();
- $store->init( $dispatcher, $this->create_data_store_with_tokens( true ) );
+ $store->init( $dispatcher, $this->create_data_store_with_tokens( true ), $this->createMock( NotificationStepLogger::class ) );
$store->register();
$store->add( $this->create_stock_mock( 42, StockNotification::EVENT_LOW_STOCK ) );
$store->add( $this->create_stock_mock( 43, StockNotification::EVENT_LOW_STOCK ) );
@@ -342,7 +421,7 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
->method( 'dispatch' );
$store = new PendingNotificationStore();
- $store->init( $dispatcher, $this->create_data_store_with_tokens( true ) );
+ $store->init( $dispatcher, $this->create_data_store_with_tokens( true ), $this->createMock( NotificationStepLogger::class ) );
$store->register();
$store->dispatch_all();
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewOrderNotificationTriggerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewOrderNotificationTriggerTest.php
index 69785b7e054..cb3fd062439 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewOrderNotificationTriggerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewOrderNotificationTriggerTest.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Triggers;
use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificationTrigger;
use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
use WC_Order;
@@ -41,7 +42,7 @@ class NewOrderNotificationTriggerTest extends WC_Unit_Test_Case {
$dispatcher = $this->createMock( InternalNotificationDispatcher::class );
$this->store = new PendingNotificationStore();
- $this->store->init( $dispatcher, $this->create_data_store_with_tokens( true ) );
+ $this->store->init( $dispatcher, $this->create_data_store_with_tokens( true ), $this->createMock( NotificationStepLogger::class ) );
$this->store->register();
wc_get_container()->replace( PendingNotificationStore::class, $this->store );
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewReviewNotificationTriggerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewReviewNotificationTriggerTest.php
index 0aaf8ef5baa..2657a2c429c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewReviewNotificationTriggerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewReviewNotificationTriggerTest.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Triggers;
use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewReviewNotificationTrigger;
use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
use WC_Helper_Product;
@@ -48,7 +49,7 @@ class NewReviewNotificationTriggerTest extends WC_Unit_Test_Case {
$dispatcher = $this->createMock( InternalNotificationDispatcher::class );
$this->store = new PendingNotificationStore();
- $this->store->init( $dispatcher, $this->create_data_store_with_tokens( true ) );
+ $this->store->init( $dispatcher, $this->create_data_store_with_tokens( true ), $this->createMock( NotificationStepLogger::class ) );
$this->store->register();
wc_get_container()->replace( PendingNotificationStore::class, $this->store );
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/StockNotificationTriggerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/StockNotificationTriggerTest.php
index 0d25c35374e..47b4becb50f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/StockNotificationTriggerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/StockNotificationTriggerTest.php
@@ -7,6 +7,7 @@ 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\Services\NotificationStepLogger;
use Automattic\WooCommerce\Internal\PushNotifications\Triggers\StockNotificationTrigger;
use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
use WC_Helper_Product;
@@ -42,7 +43,7 @@ class StockNotificationTriggerTest extends WC_Unit_Test_Case {
$dispatcher = $this->createMock( InternalNotificationDispatcher::class );
$this->store = new PendingNotificationStore();
- $this->store->init( $dispatcher, $this->create_data_store_with_tokens( true ) );
+ $this->store->init( $dispatcher, $this->create_data_store_with_tokens( true ), $this->createMock( NotificationStepLogger::class ) );
$this->store->register();
wc_get_container()->replace( PendingNotificationStore::class, $this->store );