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
+		};
+	}
+}