Commit 3367cdc613a for woocommerce
commit 3367cdc613a265b62320237fad85ae8b5b0df162
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date: Wed Sep 30 18:22:21 2026 +0100
Add a step logger foundations for push notification diagnostics (#68748)
Add a step logger for push notification diagnostics
Writes one log line per stage a notification passes through, keyed to the
notification and to each device token. The call sites follow separately.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php
new file mode 100644
index 00000000000..67a07690d9f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php
@@ -0,0 +1,279 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Services;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Throwable;
+
+/**
+ * Records each step a push notification takes, from trigger to send, as
+ * 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
+ * same type twice in a day repeats it, and a run is read from its trigger step
+ * to its terminal outcome.
+ *
+ * @since 11.3.0
+ */
+class NotificationStepLogger {
+ /**
+ * Source prefix for notification-level lines; the notification type follows.
+ */
+ const NOTIFICATION_SOURCE_PREFIX = 'push-notifications-';
+
+ /**
+ * Source prefix for per-device lines; the token post ID follows.
+ */
+ const TOKEN_SOURCE_PREFIX = 'push-token-';
+
+ /**
+ * Whether step logging is active for this request, or null until checked.
+ *
+ * @var bool|null
+ */
+ private ?bool $active = null;
+
+ /**
+ * 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 $outcome Machine-readable outcome, e.g. `ok` or `no_tokens`.
+ * @param array $context Extra scalar fields to store with the line.
+ * @return void
+ *
+ * @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 );
+ }
+
+ /**
+ * Records a per-device step for one token.
+ *
+ * @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.
+ * @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 );
+ }
+
+ /**
+ * Records a failure as an error or warning under the module's own source,
+ * which is never switched off, and as a step in the notification's journey.
+ * The error line carries the same identifying fields as the step, so a store
+ * whose log threshold drops `info` still has a line the read path can join
+ * to the notification.
+ *
+ * Where identifying the notification fails, the error line is still written,
+ * without those fields.
+ *
+ * @param Notification $notification The notification the failure belongs to.
+ * @param string $step Machine-readable step name, e.g. `send`.
+ * @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.
+ * @param array $context Extra scalar fields to store with both lines.
+ * @return void
+ *
+ * @since 11.3.0
+ */
+ public function log_failure( Notification $notification, string $step, string $outcome, string $level, string $message, array $context = array() ): void {
+ try {
+ $identified = self::identify( $notification, $step, $outcome, $context );
+ } catch ( Throwable $e ) {
+ $this->log_unattributed_failure( $step, $outcome, $level, $message, $context );
+ return;
+ }
+
+ $this->write_error( $level, $message, $identified );
+ $this->log_notification_step( $notification, $step, $outcome, $context );
+ }
+
+ /**
+ * Records a failure that happened before a notification could be
+ * identified, such as a loopback request that failed authorization.
+ *
+ * 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 $outcome Machine-readable outcome, e.g. `auth_failed`.
+ * @param string $level `error` or `warning`.
+ * @param string $message Human-readable message for the error log.
+ * @param array $context Extra scalar fields to store with the line.
+ * @return void
+ *
+ * @since 11.3.0
+ */
+ public function log_unattributed_failure( string $step, string $outcome, string $level, string $message, array $context = array() ): void {
+ $context['step'] = $step;
+ $context['outcome'] = $outcome;
+
+ $this->write_error( $level, $message, $context );
+ }
+
+ /**
+ * Builds the log source for a notification type.
+ *
+ * @param Notification $notification The notification.
+ * @return string
+ *
+ * @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() ) );
+ }
+
+ /**
+ * Builds the log source for a push token.
+ *
+ * @param int $token_id The push token post ID.
+ * @return string
+ *
+ * @since 11.3.0
+ */
+ public static function get_token_source( int $token_id ): string {
+ return self::TOKEN_SOURCE_PREFIX . $token_id;
+ }
+
+ /**
+ * Whether step logging is active, decided once per request.
+ *
+ * A filter callback that throws turns logging off, since callers outside
+ * this class read the answer without guarding against it.
+ *
+ * @return bool
+ *
+ * @since 11.3.0
+ */
+ public function is_active(): bool {
+ if ( null !== $this->active ) {
+ return $this->active;
+ }
+
+ try {
+ /**
+ * 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.
+ *
+ * @since 11.3.0
+ *
+ * @param bool $enabled Whether step logging is enabled. Default true.
+ */
+ $enabled = (bool) apply_filters( 'woocommerce_push_notification_step_logging_enabled', true );
+ } catch ( Throwable $e ) {
+ $enabled = false;
+ }
+
+ $this->active = $enabled;
+
+ return $this->active;
+ }
+
+ /**
+ * Writes one line, swallowing any failure so logging can never break a send.
+ *
+ * The source is built here rather than by the caller, because
+ * 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.
+ * @return void
+ */
+ private function write( Notification $notification, ?int $token_id, string $step, string $outcome, array $context ): 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 );
+
+ wc_get_logger()->info( self::format_message( $step, $outcome ), $context );
+ } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+ // Deliberately ignored: a logging failure must not affect the send.
+ }
+ }
+
+ /**
+ * Writes one line to the module's own error source, swallowing any failure.
+ *
+ * @param string $level `error` or `warning`.
+ * @param string $message Human-readable message.
+ * @param array $context Fields to store with the line.
+ * @return void
+ */
+ private function write_error( string $level, string $message, array $context ): void {
+ try {
+ $context['source'] = PushNotifications::FEATURE_NAME;
+ $context['remote-logging'] = false;
+
+ wc_get_logger()->log( $level, $message, $context );
+ } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+ // Deliberately ignored: a logging failure must not affect the send.
+ }
+ }
+
+ /**
+ * Adds the fields every line needs to be joined back to its notification.
+ * The caller's context cannot overwrite them.
+ *
+ * @param Notification $notification The notification.
+ * @param string $step Machine-readable step name.
+ * @param string $outcome Machine-readable outcome.
+ * @param array $context The caller's context.
+ * @return array
+ */
+ private static function identify( Notification $notification, string $step, string $outcome, array $context ): array {
+ return array_merge(
+ $context,
+ array(
+ 'identifier' => $notification->get_identifier(),
+ 'type' => $notification->get_type(),
+ 'resource_id' => $notification->get_resource_id(),
+ 'step' => $step,
+ 'outcome' => $outcome,
+ 'remote-logging' => false,
+ )
+ );
+ }
+
+ /**
+ * Builds the human-readable message for a line. Readers filter on the
+ * context fields, never on this text.
+ *
+ * @param string $step Machine-readable step name.
+ * @param string $outcome Machine-readable outcome.
+ * @return string
+ */
+ private static function format_message( string $step, string $outcome ): string {
+ return sprintf( '%s: %s', ucfirst( str_replace( '_', ' ', $step ) ), str_replace( '_', ' ', $outcome ) );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationStepLoggerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationStepLoggerTest.php
new file mode 100644
index 00000000000..752041d515c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationStepLoggerTest.php
@@ -0,0 +1,401 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Services;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
+use WC_Log_Levels;
+use WC_Logger_Interface;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the NotificationStepLogger class.
+ */
+class NotificationStepLoggerTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var NotificationStepLogger
+ */
+ private $sut;
+
+ /**
+ * The fake logger capturing calls.
+ *
+ * @var object
+ */
+ private $logger;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->sut = new NotificationStepLogger();
+
+ $this->logger = $this->create_fake_logger();
+ $logger = $this->logger;
+ add_filter(
+ 'woocommerce_logging_class',
+ static function () use ( $logger ) {
+ return $logger;
+ }
+ );
+ }
+
+ /**
+ * @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->assertCount( 1, $this->logger->info_calls );
+
+ $context = $this->logger->info_calls[0]['context'];
+ $this->assertSame( 'push-notifications-store-order', $context['source'] );
+ $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( 3, $context['recipients'] );
+ $this->assertFalse( $context['remote-logging'] );
+ $this->assertSame( 'Cleared to send: ok', $this->logger->info_calls[0]['message'] );
+ }
+
+ /**
+ * @testdox Should write a token step to that token's source with the token and user IDs.
+ */
+ 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' );
+
+ $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( get_current_blog_id() . '_store_order_42', $context['identifier'] );
+ $this->assertSame( 'held_back', $context['step'] );
+ $this->assertSame( 'type_disabled', $context['outcome'] );
+ }
+
+ /**
+ * @testdox Should carry the stock event type in the identifier so same-product events stay distinct.
+ */
+ public function test_stock_identifier_includes_the_event_type(): void {
+ $notification = $this->getMockBuilder( StockNotification::class )
+ ->setConstructorArgs( array( 99, StockNotification::EVENT_LOW_STOCK ) )
+ ->onlyMethods( array( 'to_payload', 'has_meta', 'write_meta' ) )
+ ->getMock();
+
+ $this->sut->log_notification_step( $notification, 'triggered', 'ok' );
+
+ $this->assertSame( 'push-notifications-store-stock', $this->logger->info_calls[0]['context']['source'] );
+ $this->assertSame( $notification->get_identifier(), $this->logger->info_calls[0]['context']['identifier'] );
+ }
+
+ /**
+ * @testdox Should not let a caller's context overwrite the identifying fields.
+ */
+ public function test_caller_context_cannot_overwrite_identifying_fields(): void {
+ $this->sut->log_notification_step(
+ $this->create_order_mock( 42 ),
+ 'triggered',
+ 'ok',
+ array(
+ 'identifier' => 'forged',
+ 'source' => 'elsewhere',
+ )
+ );
+
+ $context = $this->logger->info_calls[0]['context'];
+ $this->assertSame( 'push-notifications-store-order', $context['source'] );
+ $this->assertSame( get_current_blog_id() . '_store_order_42', $context['identifier'] );
+ }
+
+ /**
+ * @testdox Should write nothing when the filter returns false.
+ */
+ public function test_writes_nothing_when_the_filter_disables_logging(): void {
+ add_filter( 'woocommerce_push_notification_step_logging_enabled', '__return_false' );
+
+ $this->sut->log_notification_step( $this->create_order_mock( 42 ), 'triggered', 'ok' );
+
+ $this->assertCount( 0, $this->logger->info_calls );
+ $this->assertFalse( $this->sut->is_active() );
+ }
+
+ /**
+ * @testdox Should swallow a throwing sanitize_title callback rather than let it reach the send.
+ */
+ public function test_swallows_a_throwing_sanitize_title_callback(): void {
+ add_filter(
+ 'sanitize_title',
+ static function () {
+ throw new \RuntimeException( 'callback exploded' );
+ }
+ );
+
+ $this->sut->log_notification_step( $this->create_order_mock( 42 ), 'triggered', 'ok' );
+
+ $this->assertCount( 0, $this->logger->info_calls );
+ }
+
+ /**
+ * @testdox Should treat a filter callback that throws as logging turned off.
+ */
+ public function test_a_throwing_filter_turns_logging_off(): void {
+ add_filter(
+ 'woocommerce_push_notification_step_logging_enabled',
+ static function () {
+ throw new \RuntimeException( 'callback exploded' );
+ }
+ );
+
+ $this->sut->log_notification_step( $this->create_order_mock( 42 ), 'triggered', 'ok' );
+
+ $this->assertFalse( $this->sut->is_active() );
+ $this->assertCount( 0, $this->logger->info_calls );
+ }
+
+ /**
+ * @testdox Should decide whether logging is active once per request.
+ */
+ public function test_activation_is_decided_once(): void {
+ $applications = 0;
+ add_filter(
+ 'woocommerce_push_notification_step_logging_enabled',
+ static function ( $enabled ) use ( &$applications ) {
+ ++$applications;
+ return $enabled;
+ }
+ );
+
+ $this->sut->log_notification_step( $this->create_order_mock( 42 ), 'triggered', 'ok' );
+ $this->sut->log_notification_step( $this->create_order_mock( 42 ), 'queued', 'ok' );
+
+ $this->assertSame( 1, $applications );
+ $this->assertCount( 2, $this->logger->info_calls );
+ }
+
+ /**
+ * @testdox Should swallow a logger failure so the send is unaffected.
+ */
+ public function test_swallows_logger_failures(): void {
+ add_filter(
+ 'woocommerce_logging_class',
+ static function () {
+ return new class() implements WC_Logger_Interface {
+ // phpcs:disable Squiz.Commenting, Generic.CodeAnalysis.UnusedFunctionParameter
+ public function add( $handle, $message, $level = WC_Log_Levels::NOTICE ) {
+ return true;
+ }
+ public function log( $level, $message, $context = array() ) {}
+ public function emergency( $message, $context = array() ) {}
+ public function alert( $message, $context = array() ) {}
+ public function critical( $message, $context = array() ) {}
+ public function error( $message, $context = array() ) {}
+ public function warning( $message, $context = array() ) {}
+ public function notice( $message, $context = array() ) {}
+ public function debug( $message, $context = array() ) {}
+ public function info( $message, $context = array() ) {
+ throw new \RuntimeException( 'disk full' );
+ }
+ // phpcs:enable
+ };
+ },
+ 20
+ );
+
+ $this->sut->log_notification_step( $this->create_order_mock( 42 ), 'triggered', 'ok' );
+
+ $this->assertTrue( true, 'No exception reached the caller.' );
+ }
+
+ /**
+ * @testdox Should record a failure as an error line under the module source and as a step line under the notification source.
+ */
+ public function test_log_failure_writes_an_error_line_and_a_step_line(): void {
+ $this->sut->log_failure(
+ $this->create_order_mock( 42 ),
+ 'send',
+ 'request_failed',
+ 'error',
+ 'Push notification request failed.',
+ array( 'http_status' => 500 )
+ );
+
+ $this->assertCount( 1, $this->logger->log_calls );
+ $this->assertCount( 1, $this->logger->info_calls );
+
+ $error = $this->logger->log_calls[0];
+ $this->assertSame( 'error', $error['level'] );
+ $this->assertSame( 'Push notification request failed.', $error['message'] );
+ $this->assertSame( 'push_notifications', $error['context']['source'] );
+ $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( '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( 'push-notifications-store-order', $step['context']['source'] );
+ $this->assertSame( 500, $step['context']['http_status'] );
+ }
+
+ /**
+ * @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->assertSame( 'warning', $this->logger->log_calls[0]['level'] );
+ }
+
+ /**
+ * @testdox Should still record the error line when identifying the notification fails.
+ */
+ public function test_log_failure_records_the_error_when_identification_fails(): void {
+ $notification = $this->getMockBuilder( NewOrderNotification::class )
+ ->setConstructorArgs( array( 42 ) )
+ ->onlyMethods( array( 'to_payload', 'has_meta', 'write_meta', 'get_identifier' ) )
+ ->getMock();
+ $notification->method( 'get_identifier' )->willThrowException( new \RuntimeException( 'getter exploded' ) );
+
+ $this->sut->log_failure( $notification, 'send', 'request_failed', 'error', 'Push notification request failed.' );
+
+ $this->assertCount( 0, $this->logger->info_calls );
+ $this->assertCount( 1, $this->logger->log_calls );
+
+ $error = $this->logger->log_calls[0];
+ $this->assertSame( 'error', $error['level'] );
+ $this->assertSame( 'Push notification request failed.', $error['message'] );
+ $this->assertSame( 'push_notifications', $error['context']['source'] );
+ $this->assertArrayNotHasKey( 'identifier', $error['context'] );
+ }
+
+ /**
+ * @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->assertCount( 0, $this->logger->info_calls );
+ $this->assertCount( 1, $this->logger->log_calls );
+
+ $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( 'auth_failed', $error['context']['outcome'] );
+ $this->assertSame( 'token_invalid', $error['context']['reason'] );
+ $this->assertArrayNotHasKey( 'identifier', $error['context'] );
+ }
+
+ /**
+ * @testdox Should keep writing the error line when the filter turns step logging off.
+ */
+ 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->assertCount( 0, $this->logger->info_calls );
+ $this->assertCount( 2, $this->logger->log_calls );
+ }
+
+ /**
+ * @testdox Should swallow a logger failure while writing an error so the send is unaffected.
+ */
+ public function test_swallows_logger_failures_when_writing_an_error(): void {
+ add_filter(
+ 'woocommerce_logging_class',
+ static function () {
+ return new class() implements WC_Logger_Interface {
+ // phpcs:disable Squiz.Commenting, Generic.CodeAnalysis.UnusedFunctionParameter
+ public function add( $handle, $message, $level = WC_Log_Levels::NOTICE ) {
+ return true;
+ }
+ public function log( $level, $message, $context = array() ) {
+ throw new \RuntimeException( 'disk full' );
+ }
+ public function emergency( $message, $context = array() ) {}
+ public function alert( $message, $context = array() ) {}
+ public function critical( $message, $context = array() ) {}
+ public function error( $message, $context = array() ) {}
+ public function warning( $message, $context = array() ) {}
+ public function notice( $message, $context = array() ) {}
+ public function debug( $message, $context = array() ) {}
+ public function info( $message, $context = array() ) {}
+ // phpcs:enable
+ };
+ },
+ 20
+ );
+
+ $this->sut->log_unattributed_failure( 'received', 'auth_failed', 'warning', 'Loopback request refused.' );
+
+ $this->assertTrue( true, 'No exception reached the caller.' );
+ }
+
+ /**
+ * Creates a mock NewOrderNotification that avoids database calls.
+ *
+ * @param int $resource_id The resource ID.
+ * @return NewOrderNotification
+ */
+ private function create_order_mock( int $resource_id ): NewOrderNotification {
+ return $this->getMockBuilder( NewOrderNotification::class )
+ ->setConstructorArgs( array( $resource_id ) )
+ ->onlyMethods( array( 'to_payload', 'has_meta', 'write_meta' ) )
+ ->getMock();
+ }
+
+ /**
+ * Creates a fake logger that records info and log calls.
+ *
+ * @return object
+ */
+ private function create_fake_logger(): object {
+ return new class() implements WC_Logger_Interface {
+ // phpcs:disable Squiz.Commenting, Generic.CodeAnalysis.UnusedFunctionParameter
+ public array $info_calls = array();
+ public array $log_calls = array();
+
+ public function add( $handle, $message, $level = WC_Log_Levels::NOTICE ) {
+ return true;
+ }
+ public function log( $level, $message, $context = array() ) {
+ $this->log_calls[] = array(
+ 'level' => $level,
+ 'message' => $message,
+ 'context' => $context,
+ );
+ }
+ public function emergency( $message, $context = array() ) {}
+ public function alert( $message, $context = array() ) {}
+ public function critical( $message, $context = array() ) {}
+ public function error( $message, $context = array() ) {}
+ public function warning( $message, $context = array() ) {}
+ public function notice( $message, $context = array() ) {}
+ public function debug( $message, $context = array() ) {}
+ public function info( $message, $context = array() ) {
+ $this->info_calls[] = array(
+ 'message' => $message,
+ 'context' => $context,
+ );
+ }
+ // phpcs:enable
+ };
+ }
+}