Commit fadb4bf39e1 for woocommerce

commit fadb4bf39e1aab7e31fb6a74c749c218b8401c4f
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Mon Oct 12 18:16:00 2026 +0100

    Add endpoints for reading the push notification step log (#68750)

    * Add endpoints for reading the push notification step log

    Adds three read-only routes under wc-push-notifications/step-logs that WPCOM calls to fetch the
    journey of one notification, the attempts made against one device, and the attempts made against
    every device a user owns, each within a time range. Files are opened by day name rather than by
    listing the log directory, and stores on the database log handler get a bounded scan of the log
    table with covered_from saying how far back it reached. Every response carries the store's logging
    state so a screen with no rows can say why.

    * Fix step log paging and double-counted failures

    A reader stops once it holds a page, so when one source filled the page
    exactly, the response had no next_to and older lines could not be read.
    The readers now say when they stopped with lines left unread, and the
    response carries next_to in that case. A failure logged as both an error
    and a step line now counts once.

    Also keep the file reader tests out of the real log folder, make the token
    order test deterministic, and tidy the controller's imports and docblocks.

    * Fix step log table reads splitting a second across windows

    The table reader stopped as soon as it held a page, even when the page's
    last second carried on into the next window of IDs. Those rows were then
    skipped, since the next page starts a second earlier. It now reads on
    until a full page is newer than the window's oldest row.

    ---------

    Co-authored-by: Hicham Boushaba <hicham.boushaba@gmail.com>

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/StepLogRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/StepLogRestController.php
new file mode 100644
index 00000000000..b0643555632
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/StepLogRestController.php
@@ -0,0 +1,341 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Controllers;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\StepLogQuery;
+use Automattic\WooCommerce\Internal\PushNotifications\Traits\AuthorizesPushNotificationRequests;
+use Automattic\WooCommerce\Internal\RestApiControllerBase;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+
+/**
+ * Read-only REST endpoints through which Mission Control reads the push
+ * notification step log: everything recorded on the store, the journey of one
+ * notification, and the attempts made against one device or one user's devices.
+ *
+ * Only WPCOM can call these. Every response carries the store's logging state
+ * and the earliest time the read covered, so a screen with no rows can say why.
+ *
+ * @since 11.3.0
+ */
+class StepLogRestController extends RestApiControllerBase {
+	use AuthorizesPushNotificationRequests;
+
+	/**
+	 * How far back a request reads when it gives no `from`.
+	 */
+	const DEFAULT_RANGE_SECONDS = 2 * DAY_IN_SECONDS;
+
+	/**
+	 * The longest range a request may ask for.
+	 *
+	 * The file reader opens one path per day per rotation per source, so an
+	 * unbounded range is a stat call per day of however long the caller asked
+	 * for. Set past the 30 day default log retention, since nothing older than
+	 * that survives to be read anyway.
+	 */
+	const MAX_RANGE_SECONDS = 90 * DAY_IN_SECONDS;
+
+	/**
+	 * The root namespace for the JSON REST API endpoints.
+	 *
+	 * @var string
+	 */
+	protected string $route_namespace = 'wc-push-notifications';
+
+	/**
+	 * The REST base for the endpoints URL.
+	 *
+	 * @var string
+	 */
+	protected string $rest_base = 'step-logs';
+
+	/**
+	 * Rows returned when the caller asks for no particular number.
+	 */
+	const DEFAULT_LIMIT = 100;
+
+	/**
+	 * Most rows a caller may ask for in one page.
+	 */
+	const MAX_LIMIT = 500;
+
+	/**
+	 * The step log query service.
+	 *
+	 * @var StepLogQuery
+	 */
+	private StepLogQuery $query;
+
+	/**
+	 * Initialize injected dependencies.
+	 *
+	 * @internal
+	 *
+	 * @param StepLogQuery $query The step log query service.
+	 *
+	 * @since 11.3.0
+	 */
+	final public function init( StepLogQuery $query ): void {
+		$this->query = $query;
+	}
+
+	/**
+	 * Class identifier used by `woocommerce_rest_api_get_rest_namespaces`.
+	 *
+	 * Distinct from the URL namespace, because the filter keys one class per
+	 * value and sibling controllers share the URL namespace.
+	 *
+	 * @return string
+	 *
+	 * @since 11.3.0
+	 */
+	protected function get_rest_api_namespace(): string {
+		return 'wc-push-notifications-step-logs';
+	}
+
+	/**
+	 * Registers the routes.
+	 *
+	 * @return void
+	 *
+	 * @since 11.3.0
+	 */
+	public function register_routes(): void {
+		register_rest_route(
+			$this->route_namespace,
+			$this->rest_base . '/site',
+			array(
+				array(
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => fn ( WP_REST_Request $request ) => $this->run( $request, 'get_site' ),
+					'permission_callback' => array( $this, 'authorize_as_from_wpcom' ),
+					'args'                => $this->get_range_args(),
+				),
+			)
+		);
+
+		register_rest_route(
+			$this->route_namespace,
+			$this->rest_base . '/notifications/(?P<type>[a-z_]+)/(?P<resource_id>\d+)',
+			array(
+				array(
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => fn ( WP_REST_Request $request ) => $this->run( $request, 'get_notification' ),
+					'permission_callback' => array( $this, 'authorize_as_from_wpcom' ),
+					'args'                => array_merge(
+						array(
+							'type'        => array(
+								'type'              => 'string',
+								'required'          => true,
+								'enum'              => array_keys( Notification::NOTIFICATION_CLASSES ),
+								'sanitize_callback' => 'sanitize_key',
+								'validate_callback' => 'rest_validate_request_arg',
+							),
+							'resource_id' => array(
+								'type'              => 'integer',
+								'required'          => true,
+								'minimum'           => 1,
+								'sanitize_callback' => 'absint',
+								'validate_callback' => 'rest_validate_request_arg',
+							),
+						),
+						$this->get_range_args()
+					),
+				),
+			)
+		);
+
+		register_rest_route(
+			$this->route_namespace,
+			$this->rest_base . '/tokens/(?P<token_id>\d+)',
+			array(
+				array(
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => fn ( WP_REST_Request $request ) => $this->run( $request, 'get_token' ),
+					'permission_callback' => array( $this, 'authorize_as_from_wpcom' ),
+					'args'                => array_merge(
+						array(
+							'token_id' => array(
+								'type'              => 'integer',
+								'required'          => true,
+								'minimum'           => 1,
+								'sanitize_callback' => 'absint',
+								'validate_callback' => 'rest_validate_request_arg',
+							),
+						),
+						$this->get_range_args()
+					),
+				),
+			)
+		);
+
+		register_rest_route(
+			$this->route_namespace,
+			$this->rest_base . '/users/(?P<user_id>\d+)',
+			array(
+				array(
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => fn ( WP_REST_Request $request ) => $this->run( $request, 'get_user' ),
+					'permission_callback' => array( $this, 'authorize_as_from_wpcom' ),
+					'args'                => array_merge(
+						array(
+							'user_id' => array(
+								'type'              => 'integer',
+								'required'          => true,
+								'minimum'           => 1,
+								'sanitize_callback' => 'absint',
+								'validate_callback' => 'rest_validate_request_arg',
+							),
+						),
+						$this->get_range_args()
+					),
+				),
+			)
+		);
+	}
+
+	/**
+	 * Handles the notification journey request.
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 * @return WP_REST_Response
+	 *
+	 * @since 11.3.0
+	 */
+	protected function get_notification( WP_REST_Request $request ): WP_REST_Response {
+		list( $from, $to ) = $this->get_range( $request );
+
+		return new WP_REST_Response(
+			$this->query->for_notification(
+				(string) $request->get_param( 'type' ),
+				(int) $request->get_param( 'resource_id' ),
+				$from,
+				$to,
+				$this->get_limit( $request )
+			)
+		);
+	}
+
+	/**
+	 * Handles the whole-store request.
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 * @return WP_REST_Response
+	 *
+	 * @since 11.3.0
+	 */
+	protected function get_site( WP_REST_Request $request ): WP_REST_Response {
+		list( $from, $to ) = $this->get_range( $request );
+
+		return new WP_REST_Response( $this->query->for_site( $from, $to, $this->get_limit( $request ) ) );
+	}
+
+	/**
+	 * Handles the device request.
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 * @return WP_REST_Response
+	 *
+	 * @since 11.3.0
+	 */
+	protected function get_token( WP_REST_Request $request ): WP_REST_Response {
+		list( $from, $to ) = $this->get_range( $request );
+
+		return new WP_REST_Response( $this->query->for_token( (int) $request->get_param( 'token_id' ), $from, $to, $this->get_limit( $request ) ) );
+	}
+
+	/**
+	 * Handles the user request.
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 * @return WP_REST_Response
+	 *
+	 * @since 11.3.0
+	 */
+	protected function get_user( WP_REST_Request $request ): WP_REST_Response {
+		list( $from, $to ) = $this->get_range( $request );
+
+		return new WP_REST_Response( $this->query->for_user( (int) $request->get_param( 'user_id' ), $from, $to, $this->get_limit( $request ) ) );
+	}
+
+	/**
+	 * The `from` and `to` arguments every route accepts, as Unix timestamps.
+	 *
+	 * @return array
+	 */
+	private function get_range_args(): array {
+		return array(
+			'limit' => array(
+				'description'       => __( 'Rows to return, newest first. A page carries every row of its last second, so it can exceed this.', 'woocommerce' ),
+				'type'              => 'integer',
+				'default'           => self::DEFAULT_LIMIT,
+				'minimum'           => 1,
+				'maximum'           => self::MAX_LIMIT,
+				'sanitize_callback' => 'absint',
+				'validate_callback' => 'rest_validate_request_arg',
+			),
+			'from'  => array(
+				'description'       => __( 'Earliest Unix timestamp to include. Defaults to 48 hours before `to`, and is clamped to 90 days before it.', 'woocommerce' ),
+				'type'              => 'integer',
+				'minimum'           => 0,
+				'sanitize_callback' => 'absint',
+				'validate_callback' => 'rest_validate_request_arg',
+			),
+			'to'    => array(
+				'description'       => __( 'Latest Unix timestamp to include. Defaults to now.', 'woocommerce' ),
+				'type'              => 'integer',
+				'minimum'           => 0,
+				'sanitize_callback' => 'absint',
+				'validate_callback' => 'rest_validate_request_arg',
+			),
+		);
+	}
+
+	/**
+	 * The page size the request asked for, or the default.
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 * @return int
+	 */
+	private function get_limit( WP_REST_Request $request ): int {
+		$limit = (int) $request->get_param( 'limit' );
+
+		return $limit > 0 ? $limit : self::DEFAULT_LIMIT;
+	}
+
+	/**
+	 * Resolves the requested time range, applying the defaults.
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 * @return array{0: int, 1: int}
+	 */
+	private function get_range( WP_REST_Request $request ): array {
+		$to   = (int) $request->get_param( 'to' );
+		$from = (int) $request->get_param( 'from' );
+
+		if ( $to <= 0 ) {
+			$to = time();
+		}
+
+		if ( $from <= 0 ) {
+			$from = $to - self::DEFAULT_RANGE_SECONDS;
+		}
+
+		$from = min( $from, $to );
+
+		return array( max( $from, $to - self::MAX_RANGE_SECONDS ), $to );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 14a2de8287c..1499c8948b8 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -471,6 +471,36 @@ class PushTokensDataStore {
 		return $this->token_count;
 	}

+	/**
+	 * Returns the IDs of the push tokens a user owns, most recently registered
+	 * first.
+	 *
+	 * Capped, because the step log read tests every line against every ID
+	 * returned. Re-registering a device updates its post, so ordering by
+	 * modification time keeps the devices still in use and drops the ones
+	 * abandoned longest ago.
+	 *
+	 * @since 11.3.0
+	 * @param int $user_id The user.
+	 * @param int $limit   The most IDs to return.
+	 * @return int[]
+	 */
+	public function get_token_ids_for_user( int $user_id, int $limit = 100 ): array {
+		global $wpdb;
+
+		return array_map(
+			'intval',
+			$wpdb->get_col(
+				$wpdb->prepare(
+					"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private' AND post_author = %d ORDER BY post_modified DESC LIMIT %d",
+					PushToken::POST_TYPE,
+					$user_id,
+					$limit
+				)
+			)
+		);
+	}
+
 	/**
 	 * Returns push tokens belonging to users with the given roles.
 	 *
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/StepLogFileReader.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/StepLogFileReader.php
new file mode 100644
index 00000000000..d8773914649
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/StepLogFileReader.php
@@ -0,0 +1,242 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\DataStores;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\File;
+use Automattic\WooCommerce\Internal\Admin\Logging\Settings;
+use WC_Log_Levels;
+
+/**
+ * Reads push notification step log lines back from the WooCommerce log files.
+ *
+ * Files are addressed by day, never by listing the directory: the file handler
+ * names each file from its source and UTC date plus a hash of that name, so
+ * the path for any day can be built without a glob. Each file is streamed one
+ * line at a time, so memory is one line plus the matches whatever the file
+ * size.
+ *
+ * @since 11.3.0
+ */
+class StepLogFileReader {
+	/**
+	 * The file handler keeps this many rotations of one day's file before
+	 * overwriting the oldest, so this is the highest rotation suffix to probe.
+	 */
+	const MAX_ROTATIONS = 10;
+
+	/**
+	 * Returns the newest lines written under a source between two times.
+	 *
+	 * Reads one UTC day at a time, newest first, and stops once it holds enough
+	 * rows, so a short page over a long range does not open every day's files.
+	 * Rows are turned into what the caller wants as each line is read, and the
+	 * set is trimmed after every file, so neither the range nor one large file
+	 * decides how much is held at once. `has_more` says whether that left older
+	 * lines unread.
+	 *
+	 * @param string        $source The log source, e.g. `push-notifications-store-order`.
+	 * @param int           $from   Earliest timestamp to include, inclusive.
+	 * @param int           $to     Latest timestamp to include, inclusive.
+	 * @param int           $limit  The fewest rows to collect before stopping.
+	 * @param callable|null $expand Called with each row, returning a row per result it should produce.
+	 * @return array{rows: array<int, array{timestamp: int, level: string, message: string, context: array|null, raw: string|null}>, covered_from: int, has_more: bool}
+	 *
+	 * @since 11.3.0
+	 */
+	public function read( string $source, int $from, int $to, int $limit = PHP_INT_MAX, ?callable $expand = null ): array {
+		$directory    = Settings::get_log_directory( false );
+		$days         = self::get_days( $from, $to );
+		$rows         = array();
+		$covered_from = $to;
+		$has_more     = false;
+
+		foreach ( $days as $index => $day ) {
+			$covered_from = max( $from, $day );
+
+			foreach ( self::get_filenames( $source, $day ) as $filename ) {
+				$file = new File( $directory . $filename );
+
+				if ( ! $file->is_readable() ) {
+					continue;
+				}
+
+				$stream = $file->get_stream();
+
+				if ( ! is_resource( $stream ) ) {
+					continue;
+				}
+
+				while ( false !== ( $line = fgets( $stream ) ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
+					$row = self::parse_line( $line );
+
+					if ( null === $row || $row['timestamp'] < $from || $row['timestamp'] > $to ) {
+						continue;
+					}
+
+					if ( null === $expand ) {
+						$rows[] = $row;
+						continue;
+					}
+
+					foreach ( $expand( $row ) as $expanded ) {
+						$rows[] = $expanded;
+					}
+				}
+
+				$file->close_stream();
+
+				$kept     = self::keep_newest( $rows, $limit );
+				$has_more = $has_more || count( $kept ) < count( $rows );
+				$rows     = $kept;
+			}
+
+			/*
+			 * Every remaining day is older than everything held, so stopping at
+			 * a day boundary cannot skip a row that belonged on this page. The
+			 * boundary is also a second boundary, which the page's whole-second
+			 * rule needs.
+			 */
+			if ( count( $rows ) >= $limit ) {
+				$has_more = $has_more || isset( $days[ $index + 1 ] );
+				break;
+			}
+		}
+
+		usort( $rows, fn( array $a, array $b ) => $a['timestamp'] <=> $b['timestamp'] );
+
+		return array(
+			'rows'         => $rows,
+			'covered_from' => $covered_from,
+			'has_more'     => $has_more,
+		);
+	}
+
+	/**
+	 * The UTC days a range covers, newest first.
+	 *
+	 * @param int $from Earliest timestamp.
+	 * @param int $to   Latest timestamp.
+	 * @return int[] The start of each day.
+	 */
+	private static function get_days( int $from, int $to ): array {
+		$days  = array();
+		$day   = (int) strtotime( gmdate( 'Y-m-d', $from ) . 'T00:00:00+00:00' );
+		$until = (int) strtotime( gmdate( 'Y-m-d', $to ) . 'T00:00:00+00:00' );
+
+		while ( $day <= $until ) {
+			$days[] = $day;
+			$day   += DAY_IN_SECONDS;
+		}
+
+		return array_reverse( $days );
+	}
+
+	/**
+	 * Keeps the newest rows and whatever shares the oldest kept second, so a
+	 * trim can never cut a second in half.
+	 *
+	 * @param array $rows  The rows held so far.
+	 * @param int   $limit The fewest rows to keep.
+	 * @return array
+	 */
+	private static function keep_newest( array $rows, int $limit ): array {
+		if ( PHP_INT_MAX === $limit || count( $rows ) <= $limit ) {
+			return $rows;
+		}
+
+		usort( $rows, fn( array $a, array $b ) => $b['timestamp'] <=> $a['timestamp'] );
+
+		$boundary = $rows[ $limit - 1 ]['timestamp'];
+
+		return array_values( array_filter( $rows, fn( array $row ) => $row['timestamp'] >= $boundary ) );
+	}
+
+	/**
+	 * The filenames that could hold one day's lines for a source: the current
+	 * file and every rotation of it.
+	 *
+	 * @param string $source The log source.
+	 * @param int    $day    A timestamp within the UTC day.
+	 * @return string[]
+	 */
+	private static function get_filenames( string $source, int $day ): array {
+		$filenames = array();
+
+		for ( $rotation = self::MAX_ROTATIONS - 1; $rotation >= 0; $rotation-- ) {
+			$filenames[] = self::build_filename( $source, $rotation, $day );
+		}
+
+		$filenames[] = self::build_filename( $source, null, $day );
+
+		return $filenames;
+	}
+
+	/**
+	 * Builds a log filename the way the file handler does when writing.
+	 *
+	 * @param string   $source   The log source.
+	 * @param int|null $rotation The rotation suffix, or null for the current file.
+	 * @param int      $day      A timestamp within the UTC day.
+	 * @return string
+	 */
+	private static function build_filename( string $source, ?int $rotation, int $day ): string {
+		$file_id = File::generate_file_id( $source, $rotation, $day );
+
+		return $file_id . '-' . File::generate_hash( $file_id ) . '.log';
+	}
+
+	/**
+	 * Parses one line as the file handler wrote it: a timestamp, a level, the
+	 * message, and an optional ` CONTEXT: ` JSON block.
+	 *
+	 * A line whose context does not decode is still returned, with the raw
+	 * text in `raw` and no context, so a plugin reshaping the line through
+	 * `woocommerce_format_log_entry` degrades the row rather than the request.
+	 *
+	 * @param string $line The line.
+	 * @return array{timestamp: int, level: string, message: string, context: array|null, raw: string|null}|null Null for a line with no timestamp and level.
+	 *
+	 * @since 11.3.0
+	 */
+	public static function parse_line( string $line ): ?array {
+		$line     = rtrim( $line, "\r\n" );
+		$segments = explode( ' ', $line, 3 );
+
+		if ( count( $segments ) < 3 ) {
+			return null;
+		}
+
+		$timestamp = strtotime( $segments[0] );
+		$level     = strtolower( $segments[1] );
+
+		if ( false === $timestamp || ! WC_Log_Levels::is_valid_level( $level ) ) {
+			return null;
+		}
+
+		$chunks  = explode( ' CONTEXT: ', $segments[2], 2 );
+		$context = null;
+		$raw     = null;
+
+		if ( isset( $chunks[1] ) ) {
+			$decoded = json_decode( $chunks[1], true );
+
+			if ( is_array( $decoded ) ) {
+				$context = $decoded;
+			} else {
+				$raw = $line;
+			}
+		}
+
+		return array(
+			'timestamp' => $timestamp,
+			'level'     => $level,
+			'message'   => $chunks[0],
+			'context'   => $context,
+			'raw'       => $raw,
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/StepLogTableReader.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/StepLogTableReader.php
new file mode 100644
index 00000000000..3a5eb7ecffa
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/StepLogTableReader.php
@@ -0,0 +1,183 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\DataStores;
+
+defined( 'ABSPATH' ) || exit;
+
+use WC_Log_Levels;
+
+/**
+ * Reads push notification step log lines back from the `wc_log` table, for
+ * stores that chose the database log handler.
+ *
+ * The table indexes only `log_id` and `level`, so a query by source would scan
+ * every row. Instead the reader walks back from the newest row in windows of
+ * primary key IDs, which bounds the rows examined whatever the table size, and
+ * stops once it has passed the start of the requested range or examined its
+ * limit. Coverage can therefore fall short of the range on a store whose other
+ * plugins log heavily; `covered_from` says where the read stopped.
+ *
+ * @since 11.3.0
+ */
+class StepLogTableReader {
+	/**
+	 * Rows examined per window.
+	 */
+	const WINDOW_SIZE = 5000;
+
+	/**
+	 * Most rows examined in one read.
+	 */
+	const MAX_ROWS_EXAMINED = 50000;
+
+	/**
+	 * Returns every row written under a source between two times, as far back
+	 * as the bounded scan reached.
+	 *
+	 * Rows are turned into what the caller wants as each one is read, and the
+	 * scan stops once it holds enough, so a short page does not walk the whole
+	 * row budget. `has_more` says whether it stopped with older windows unread.
+	 *
+	 * @param string        $source The log source, e.g. `push-notifications-store-order`.
+	 * @param int           $from   Earliest timestamp to include, inclusive.
+	 * @param int           $to     Latest timestamp to include, inclusive.
+	 * @param int           $limit  The fewest rows to collect before stopping.
+	 * @param callable|null $expand Called with each row, returning a row per result it should produce.
+	 * @return array{rows: array<int, array{timestamp: int, level: string, message: string, context: array|null, raw: string|null}>, covered_from: int, has_more: bool}
+	 *
+	 * @since 11.3.0
+	 */
+	public function read( string $source, int $from, int $to, int $limit = PHP_INT_MAX, ?callable $expand = null ): array {
+		global $wpdb;
+
+		$table  = $wpdb->prefix . 'woocommerce_log';
+		$max_id = (int) $wpdb->get_var( "SELECT MAX(log_id) FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$rows   = array();
+
+		$upper        = $max_id;
+		$examined     = 0;
+		$covered_from = $from;
+		$has_more     = false;
+
+		while ( $upper > 0 && $examined < self::MAX_ROWS_EXAMINED ) {
+			$lower = max( 0, $upper - self::WINDOW_SIZE );
+
+			$results = $wpdb->get_results(
+				$wpdb->prepare(
+					"SELECT log_id, timestamp, level, message, context FROM {$table} WHERE log_id > %d AND log_id <= %d AND source = %s ORDER BY log_id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+					$lower,
+					$upper,
+					$source
+				),
+				ARRAY_A
+			);
+
+			foreach ( (array) $results as $result ) {
+				$row = self::build_row( $result );
+
+				if ( $row['timestamp'] < $from || $row['timestamp'] > $to ) {
+					continue;
+				}
+
+				if ( null === $expand ) {
+					$rows[] = $row;
+					continue;
+				}
+
+				foreach ( $expand( $row ) as $expanded ) {
+					$rows[] = $expanded;
+				}
+			}
+
+			$examined += $upper - $lower;
+			$oldest    = $this->get_window_start_timestamp( $table, $lower );
+			$upper     = $lower;
+
+			/*
+			 * Windows are walked newest first, so everything left is older than
+			 * this window's oldest row. That row's second can carry on into the
+			 * next window, so stop only once a full page is newer than it.
+			 */
+			if ( count( array_filter( $rows, fn( array $row ) => null === $oldest || $row['timestamp'] > $oldest ) ) >= $limit ) {
+				$covered_from = null === $oldest ? $from : $oldest;
+				$has_more     = $upper > 0 && $covered_from > $from;
+				break;
+			}
+
+			if ( null === $oldest ) {
+				continue;
+			}
+
+			if ( $oldest <= $from ) {
+				$covered_from = $from;
+				break;
+			}
+
+			$covered_from = $oldest;
+		}
+
+		if ( 0 === $upper ) {
+			$covered_from = $from;
+		}
+
+		usort( $rows, fn( array $a, array $b ) => $a['timestamp'] <=> $b['timestamp'] );
+
+		return array(
+			'rows'         => $rows,
+			'covered_from' => $covered_from,
+			'has_more'     => $has_more,
+		);
+	}
+
+	/**
+	 * Returns the timestamp of the oldest row in the window just read, to
+	 * decide whether the scan has passed the start of the range.
+	 *
+	 * @param string $table The log table name.
+	 * @param int    $lower The exclusive lower ID bound of the window just read.
+	 * @return int|null Null when the window held no rows at all.
+	 */
+	private function get_window_start_timestamp( string $table, int $lower ): ?int {
+		global $wpdb;
+
+		$timestamp = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT timestamp FROM {$table} WHERE log_id > %d ORDER BY log_id ASC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+				$lower
+			)
+		);
+
+		return null === $timestamp ? null : (int) strtotime( $timestamp . ' UTC' );
+	}
+
+	/**
+	 * Converts one table row to the shape the file reader produces.
+	 *
+	 * @param array $result The row as `wpdb` returned it.
+	 * @return array{timestamp: int, level: string, message: string, context: array|null, raw: string|null}
+	 */
+	private static function build_row( array $result ): array {
+		$context = null;
+		$raw     = null;
+
+		if ( ! empty( $result['context'] ) ) {
+			$decoded = json_decode( (string) $result['context'], true );
+
+			if ( is_array( $decoded ) ) {
+				$context = $decoded;
+			} else {
+				$raw = (string) $result['context'];
+			}
+		}
+
+		return array(
+			'timestamp' => (int) strtotime( $result['timestamp'] . ' UTC' ),
+			'level'     => (string) WC_Log_Levels::get_severity_level( (int) $result['level'] ),
+			'message'   => (string) $result['message'],
+			'context'   => $context,
+			'raw'       => $raw,
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index 048c9bd55c5..3ca92dfb73e 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Controllers\NotificationPr
 use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationRestController;
 use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationStatusRestController;
 use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushTokenRestController;
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\StepLogRestController;
 use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\DriverAvailabilityService;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
@@ -86,6 +87,11 @@ class PushNotifications {
 		// write routes only while the module is enabled, like everything below.
 		( new PushTokenRestController() )->register();

+		// Registered ahead of the enablement check for the same reason: a store
+		// is most worth reading the step log for once push notifications have
+		// been switched off, which is often why they were switched off.
+		wc_get_container()->get( StepLogRestController::class )->register();
+
 		if ( ! $this->should_be_enabled() ) {
 			return;
 		}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/StepLogQuery.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/StepLogQuery.php
new file mode 100644
index 00000000000..81a6b999366
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/StepLogQuery.php
@@ -0,0 +1,541 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Services;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\Admin\Logging\LogHandlerFileV2;
+use Automattic\WooCommerce\Internal\Admin\Logging\Settings;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\StepLogFileReader;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\StepLogTableReader;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Utilities\LoggingUtil;
+use WC_Log_Handler_DB;
+
+/**
+ * Answers the questions Mission Control asks of the push notification step
+ * log: what happened to one notification, and what has been sent to one
+ * token or one user.
+ *
+ * Reads from the log files or the log table depending on which handler the
+ * store uses, and returns the store's logging state with every answer so a
+ * screen with no rows can say why.
+ *
+ * @since 11.3.0
+ */
+class StepLogQuery {
+	/**
+	 * The file reader.
+	 *
+	 * @var StepLogFileReader
+	 */
+	private StepLogFileReader $file_reader;
+
+	/**
+	 * The table reader.
+	 *
+	 * @var StepLogTableReader
+	 */
+	private StepLogTableReader $table_reader;
+
+	/**
+	 * The push tokens data store.
+	 *
+	 * @var PushTokensDataStore
+	 */
+	private PushTokensDataStore $data_store;
+
+	/**
+	 * The step logger, consulted for whether logging is active on this store.
+	 *
+	 * @var NotificationStepLogger
+	 */
+	private NotificationStepLogger $step_logger;
+
+	/**
+	 * Initialize injected dependencies.
+	 *
+	 * @internal
+	 *
+	 * @param StepLogFileReader      $file_reader  The file reader.
+	 * @param StepLogTableReader     $table_reader The table reader.
+	 * @param PushTokensDataStore    $data_store   The push tokens data store.
+	 * @param NotificationStepLogger $step_logger  The step logger.
+	 *
+	 * @since 11.3.0
+	 */
+	final public function init(
+		StepLogFileReader $file_reader,
+		StepLogTableReader $table_reader,
+		PushTokensDataStore $data_store,
+		NotificationStepLogger $step_logger
+	): void {
+		$this->file_reader  = $file_reader;
+		$this->table_reader = $table_reader;
+		$this->data_store   = $data_store;
+		$this->step_logger  = $step_logger;
+	}
+
+	/**
+	 * The order the steps run in, used only to break ties inside one second.
+	 *
+	 * @var array<string, int>
+	 */
+	private const STEP_ORDER = array(
+		'triggered'          => 1,
+		'loopback_requested' => 2,
+		'loopback_started'   => 3,
+		'fallback'           => 4,
+		'processing'         => 5,
+		'skipped'            => 6,
+		'recipients'         => 7,
+		'token_included'     => 8,
+		'token_excluded'     => 9,
+		'dispatched'         => 10,
+		'retry'              => 11,
+	);
+
+	/**
+	 * Returns every step recorded on the store.
+	 *
+	 * Reads every notification type's source, the store-wide suppressed source
+	 * and the module's error source. No source is per token, so the number of
+	 * files read does not grow with the number of devices the store holds.
+	 *
+	 * @param int $from Earliest timestamp, inclusive.
+	 * @param int $to    Latest timestamp, inclusive.
+	 * @param int $limit Rows to return, newest first.
+	 * @return array{rows: array, counts: array<string, int>, covered_from: int, next_to: int|null, logging: array}
+	 *
+	 * @since 11.3.0
+	 */
+	public function for_site( int $from, int $to, int $limit ): array {
+		$sources = array_map(
+			array( NotificationStepLogger::class, 'get_notification_source_for_type' ),
+			array_keys( Notification::NOTIFICATION_CLASSES )
+		);
+
+		$sources[] = NotificationStepLogger::SUPPRESSED_SOURCE;
+		$sources[] = PushNotifications::FEATURE_NAME;
+
+		return $this->query( $sources, $from, $to, $limit );
+	}
+
+	/**
+	 * Returns every step recorded for one resource's notifications of one
+	 * type, from the journey file and from the module's error log.
+	 *
+	 * @param string $type        The notification type, e.g. `store_order`.
+	 * @param int    $resource_id The order, comment or product ID.
+	 * @param int    $from        Earliest timestamp, inclusive.
+	 * @param int    $to          Latest timestamp, inclusive.
+	 * @param int    $limit       Rows to return, newest first.
+	 * @return array{rows: array, counts: array<string, int>, covered_from: int, next_to: int|null, logging: array}
+	 *
+	 * @since 11.3.0
+	 */
+	public function for_notification( string $type, int $resource_id, int $from, int $to, int $limit ): array {
+		$sources = array(
+			NotificationStepLogger::get_notification_source_for_type( $type ),
+			NotificationStepLogger::SUPPRESSED_SOURCE,
+			PushNotifications::FEATURE_NAME,
+		);
+
+		return $this->query(
+			$sources,
+			$from,
+			$to,
+			$limit,
+			function ( array $row ) use ( $type, $resource_id ): array {
+				$context = $row['context'] ?? array();
+
+				/*
+				 * A batch line covers many notifications, so it names each one
+				 * by type and resource ID rather than carrying one of its own.
+				 */
+				if ( isset( $context['notifications'] ) && is_array( $context['notifications'] ) ) {
+					foreach ( $context['notifications'] as $covered ) {
+						if ( isset( $covered['type'], $covered['resource_id'] )
+							&& $type === $covered['type']
+							&& $resource_id === (int) $covered['resource_id'] ) {
+							return array( $row );
+						}
+					}
+
+					return array();
+				}
+
+				return isset( $context['type'], $context['resource_id'] )
+					&& $type === $context['type']
+					&& $resource_id === (int) $context['resource_id']
+						? array( $row )
+						: array();
+			}
+		);
+	}
+
+	/**
+	 * Returns every step recorded against one token.
+	 *
+	 * No line is stored per token, so this finds the token named inside the
+	 * notification-level lines and returns a row for it.
+	 *
+	 * @param int $token_id The push token post ID.
+	 * @param int $from     Earliest timestamp, inclusive.
+	 * @param int $to       Latest timestamp, inclusive.
+	 * @param int $limit    Rows to return, newest first.
+	 * @return array{rows: array, counts: array<string, int>, covered_from: int, next_to: int|null, logging: array}
+	 *
+	 * @since 11.3.0
+	 */
+	public function for_token( int $token_id, int $from, int $to, int $limit ): array {
+		return $this->query(
+			self::get_token_sources(),
+			$from,
+			$to,
+			$limit,
+			fn( array $row ) => self::split_line_into_token_rows( $row, array( $token_id ) )
+		);
+	}
+
+	/**
+	 * Returns every step recorded against every token one user owns.
+	 *
+	 * @param int $user_id The user.
+	 * @param int $from    Earliest timestamp, inclusive.
+	 * @param int $to      Latest timestamp, inclusive.
+	 * @param int $limit   Rows to return, newest first.
+	 * @return array{rows: array, counts: array<string, int>, covered_from: int, next_to: int|null, logging: array}
+	 *
+	 * @since 11.3.0
+	 */
+	public function for_user( int $user_id, int $from, int $to, int $limit ): array {
+		$token_ids = $this->data_store->get_token_ids_for_user( $user_id );
+
+		return $this->query(
+			self::get_token_sources(),
+			$from,
+			$to,
+			$limit,
+			fn( array $row ) => self::split_line_into_token_rows( $row, $token_ids )
+		);
+	}
+
+	/**
+	 * The sources that can name a token: every notification type's own source
+	 * and the store-wide suppressed source.
+	 *
+	 * @return string[]
+	 */
+	private static function get_token_sources(): array {
+		$sources = array_map(
+			array( NotificationStepLogger::class, 'get_notification_source_for_type' ),
+			array_keys( Notification::NOTIFICATION_CLASSES )
+		);
+
+		$sources[] = NotificationStepLogger::SUPPRESSED_SOURCE;
+
+		return $sources;
+	}
+
+	/**
+	 * Splits one stored line into a row per token it names, so a per-token read
+	 * sees the same shape it would have seen when every token had a line of its
+	 * own.
+	 *
+	 * @param array $row       The stored row.
+	 * @param int[] $token_ids The tokens being asked about.
+	 * @return array[] A row per token named, which may be none.
+	 */
+	private static function split_line_into_token_rows( array $row, array $token_ids ): array {
+		$context = $row['context'] ?? array();
+		$rows    = array();
+
+		foreach ( $token_ids as $token_id ) {
+			$token_id = (int) $token_id;
+
+			foreach ( (array) ( $context['excluded_tokens'] ?? array() ) as $reason => $excluded ) {
+				if ( in_array( $token_id, array_map( 'intval', (array) $excluded ), true ) ) {
+					$rows[] = self::strip_unrelated_tokens( $row, $token_id, 'token_excluded', (string) $reason );
+					continue 2;
+				}
+			}
+
+			if ( in_array( $token_id, array_map( 'intval', (array) ( $context['invalid_token_ids'] ?? array() ) ), true ) ) {
+				$rows[] = self::strip_unrelated_tokens( $row, $token_id, 'dispatched', 'invalid_token' );
+				continue;
+			}
+
+			if ( in_array( $token_id, array_map( 'intval', (array) ( $context['token_ids'] ?? array() ) ), true ) ) {
+				$rows[] = self::strip_unrelated_tokens( $row, $token_id, 'token_included', 'ok' );
+			}
+		}
+
+		return $rows;
+	}
+
+	/**
+	 * Returns the row for one token, with every other token's data removed.
+	 *
+	 * The stored line names every token the notification touched, so the three
+	 * token lists are dropped and the step and outcome are replaced with what
+	 * happened to this one. Two rows built from the same line can therefore
+	 * carry different outcomes.
+	 *
+	 * @param array  $row      The stored row.
+	 * @param int    $token_id The token to keep.
+	 * @param string $step     The step to report for that token.
+	 * @param string $outcome  The outcome to report for that token.
+	 * @return array
+	 */
+	private static function strip_unrelated_tokens( array $row, int $token_id, string $step, string $outcome ): array {
+		$context = $row['context'] ?? array();
+
+		unset( $context['excluded_tokens'], $context['invalid_token_ids'], $context['token_ids'] );
+
+		$context['token_id'] = $token_id;
+		$context['step']     = $step;
+		$context['outcome']  = $outcome;
+
+		$row['context'] = $context;
+		$row['message'] = ucfirst( str_replace( '_', ' ', $step ) ) . ': ' . str_replace( '_', ' ', $outcome );
+
+		return $row;
+	}
+
+	/**
+	 * Returns the store's logging state: the settings that decide whether
+	 * step lines are written and kept, the store's token count, and the caps a
+	 * line's token lists are cut to, so a reader can tell a short list from a
+	 * truncated one.
+	 *
+	 * @return array
+	 *
+	 * @since 11.3.0
+	 */
+	public function get_logging_state(): array {
+		$directory = Settings::get_log_directory( false );
+
+		return array(
+			'logging_enabled'        => LoggingUtil::logging_is_enabled(),
+			'push_logging_enabled'   => $this->step_logger->is_active(),
+			'default_handler'        => LoggingUtil::get_default_handler(),
+			'level_threshold'        => LoggingUtil::get_level_threshold(),
+			'retention_period_days'  => LoggingUtil::get_retention_period(),
+			'log_directory_writable' => is_dir( $directory ) && wp_is_writable( $directory ),
+			'token_count'            => $this->data_store->count_tokens(),
+			'recipient_id_cap'       => NotificationProcessor::RECIPIENT_ID_CAP,
+			'max_array_items'        => NotificationStepLogger::MAX_ARRAY_ITEMS,
+		);
+	}
+
+	/**
+	 * Reads the sources, keeps the rows the filter accepts, and assembles the
+	 * answer.
+	 *
+	 * @param string[]      $sources The log sources to read.
+	 * @param int           $from    Earliest timestamp, inclusive.
+	 * @param int           $to      Latest timestamp, inclusive.
+	 * @param int           $limit   Rows to return, before the whole-second rule.
+	 * @param callable|null $expand  Called with each row, returning a row per result it should produce.
+	 * @return array{rows: array, counts: array<string, int>, covered_from: int, next_to: int|null, logging: array}
+	 */
+	private function query( array $sources, int $from, int $to, int $limit, ?callable $expand = null ): array {
+		$rows         = array();
+		$covered_from = $from;
+		$has_more     = false;
+
+		foreach ( $sources as $source ) {
+			/*
+			 * The readers apply the expansion as each line is read and stop
+			 * once they hold enough, so neither the range nor one large file
+			 * decides how much is held here.
+			 */
+			$result       = $this->read( $source, $from, $to, $limit, $expand );
+			$covered_from = max( $covered_from, $result['covered_from'] );
+			$has_more     = $has_more || $result['has_more'];
+			$rows         = array_merge( $rows, $result['rows'] );
+		}
+
+		usort(
+			$rows,
+			function ( array $a, array $b ): int {
+				if ( $a['timestamp'] !== $b['timestamp'] ) {
+					return $b['timestamp'] <=> $a['timestamp'];
+				}
+
+				return self::get_order_within_a_second( $b ) <=> self::get_order_within_a_second( $a );
+			}
+		);
+
+		list( $rows, $next_to ) = self::take_page( $rows, $limit, $has_more );
+
+		return array(
+			'rows'         => array_map( array( self::class, 'format_row' ), $rows ),
+			'counts'       => self::count_outcomes( $rows ),
+			'covered_from' => $covered_from,
+			'next_to'      => $next_to,
+			'logging'      => $this->get_logging_state(),
+		);
+	}
+
+	/**
+	 * Where a line sits among the lines sharing its second.
+	 *
+	 * Timestamps are whole seconds, and the request that dispatches a
+	 * notification and the loopback request that processes it run at the same
+	 * time in separate processes, so neither the timestamp nor the order the
+	 * lines were written tells a reader which step came first. Ordering ties by
+	 * the lifecycle the steps run in does.
+	 *
+	 * A step this does not name sorts to the end of its second.
+	 *
+	 * @param array $row The row.
+	 * @return int
+	 */
+	private static function get_order_within_a_second( array $row ): int {
+		return self::STEP_ORDER[ $row['context']['step'] ?? '' ] ?? 0;
+	}
+
+	/**
+	 * Takes one page off the front of the rows, with the cursor for the next.
+	 *
+	 * Timestamps are whole seconds and several steps of one notification share
+	 * a second, so a page that stopped mid-second would skip or repeat rows on
+	 * the next read. Where the limit falls inside a second the page carries
+	 * every row of that second and may exceed the limit.
+	 *
+	 * @param array $rows     Rows, newest first.
+	 * @param int   $limit    Rows to return, before the whole-second rule.
+	 * @param bool  $has_more Whether a reader stopped with older rows unread, which it only does once it holds the limit.
+	 * @return array{0: list<array>, 1: int|null} The page, and the `to` for the next page or null.
+	 */
+	private static function take_page( array $rows, int $limit, bool $has_more ): array {
+		if ( count( $rows ) <= $limit && ! $has_more ) {
+			return array( array_values( $rows ), null );
+		}
+
+		$rows     = array_values( $rows );
+		$boundary = (int) $rows[ $limit - 1 ]['timestamp'];
+		$page     = array_slice( $rows, 0, $limit );
+		$next     = $limit;
+
+		while ( isset( $rows[ $next ] ) && $boundary === (int) $rows[ $next ]['timestamp'] ) {
+			$page[] = $rows[ $next ];
+			++$next;
+		}
+
+		return array( $page, ( isset( $rows[ $next ] ) || $has_more ) ? $boundary - 1 : null );
+	}
+
+	/**
+	 * Reads one source through the reader that matches the store's handler.
+	 *
+	 * @param string        $source The log source.
+	 * @param int           $from   Earliest timestamp, inclusive.
+	 * @param int           $to     Latest timestamp, inclusive.
+	 * @param int           $limit  The fewest rows the reader collects before stopping.
+	 * @param callable|null $expand Called with each row, returning a row per result it should produce.
+	 * @return array{rows: array, covered_from: int, has_more: bool}
+	 */
+	private function read( string $source, int $from, int $to, int $limit, ?callable $expand ): array {
+		$handler = LoggingUtil::get_default_handler();
+
+		if ( is_a( $handler, WC_Log_Handler_DB::class, true ) ) {
+			return $this->table_reader->read( $source, $from, $to, $limit, $expand );
+		}
+
+		if ( is_a( $handler, LogHandlerFileV2::class, true ) ) {
+			return $this->file_reader->read( $source, $from, $to, $limit, $expand );
+		}
+
+		return array(
+			'rows'         => array(),
+			'covered_from' => $to,
+			'has_more'     => false,
+		);
+	}
+
+	/**
+	 * Counts rows per step and outcome, as `step:outcome` keys.
+	 *
+	 * A failure is logged twice, as an error line and as an `info` step line, so
+	 * each step line cancels one error line for the same notification, step and
+	 * outcome, and the failure counts once.
+	 *
+	 * @param array $rows The rows.
+	 * @return array<string, int>
+	 */
+	private static function count_outcomes( array $rows ): array {
+		$step_lines = array();
+
+		foreach ( $rows as $row ) {
+			$failure = self::get_failure_key( $row );
+
+			if ( null !== $failure && 'info' === $row['level'] ) {
+				$step_lines[ $failure ] = ( $step_lines[ $failure ] ?? 0 ) + 1;
+			}
+		}
+
+		$counts = array();
+
+		foreach ( $rows as $row ) {
+			if ( ! isset( $row['context']['step'], $row['context']['outcome'] ) ) {
+				continue;
+			}
+
+			$failure = self::get_failure_key( $row );
+
+			if ( null !== $failure && 'info' !== $row['level'] && ( $step_lines[ $failure ] ?? 0 ) > 0 ) {
+				--$step_lines[ $failure ];
+				continue;
+			}
+
+			$key            = $row['context']['step'] . ':' . $row['context']['outcome'];
+			$counts[ $key ] = ( $counts[ $key ] ?? 0 ) + 1;
+		}
+
+		return $counts;
+	}
+
+	/**
+	 * Names the notification, step and outcome a row records.
+	 *
+	 * @param array $row The row.
+	 * @return string|null Null for a row that names no notification, such as a batch line.
+	 */
+	private static function get_failure_key( array $row ): ?string {
+		$context = $row['context'] ?? array();
+
+		return isset( $context['identifier'], $context['step'], $context['outcome'] )
+			? $context['identifier'] . '|' . $context['step'] . '|' . $context['outcome']
+			: null;
+	}
+
+	/**
+	 * Shapes one row for the REST response.
+	 *
+	 * @param array $row The row as a reader produced it.
+	 * @return array
+	 */
+	private static function format_row( array $row ): array {
+		$formatted = array(
+			'timestamp' => gmdate( 'c', $row['timestamp'] ),
+			'level'     => $row['level'],
+			'message'   => $row['message'],
+			'context'   => $row['context'],
+		);
+
+		// Only a line whose context did not parse has raw text to show, and for
+		// those it is all there is, since the context is null.
+		if ( null !== $row['raw'] ) {
+			$formatted['raw'] = $row['raw'];
+		}
+
+		return $formatted;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/StepLogRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/StepLogRestControllerTest.php
new file mode 100644
index 00000000000..3681f35e739
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/StepLogRestControllerTest.php
@@ -0,0 +1,239 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Controllers;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\StepLogRestController;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\StepLogQuery;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
+use WC_Unit_Test_Case;
+use WP_REST_Request;
+
+/**
+ * Tests for the StepLogRestController class.
+ */
+class StepLogRestControllerTest extends WC_Unit_Test_Case {
+	use PushNotificationsTestTrait;
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var StepLogRestController
+	 */
+	private $sut;
+
+	/**
+	 * Mock query service.
+	 *
+	 * @var StepLogQuery|\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private $query;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->query = $this->createMock( StepLogQuery::class );
+		$this->sut   = new StepLogRestController();
+		$this->sut->init( $this->query );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		$this->reset_container_replacements();
+		wc_get_container()->reset_all_resolved();
+		$this->clear_rest_server();
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should register the four step log routes.
+	 */
+	public function test_register_routes_adds_the_routes(): void {
+		$server = $this->create_rest_server_with_routes( array( array( $this->sut, 'register_routes' ) ), true );
+		$routes = array_keys( $server->get_routes( 'wc-push-notifications' ) );
+
+		$this->assertContains( '/wc-push-notifications/step-logs/site', $routes );
+		$this->assertContains( '/wc-push-notifications/step-logs/notifications/(?P<type>[a-z_]+)/(?P<resource_id>\d+)', $routes );
+		$this->assertContains( '/wc-push-notifications/step-logs/tokens/(?P<token_id>\d+)', $routes );
+		$this->assertContains( '/wc-push-notifications/step-logs/users/(?P<user_id>\d+)', $routes );
+	}
+
+	/**
+	 * @testdox Should refuse a request that is not signed with the Jetpack blog token.
+	 */
+	public function test_authorize_rejects_without_blog_token(): void {
+		$this->mock_jetpack_connection_manager_is_connected();
+
+		$result = $this->sut->authorize_as_from_wpcom( new WP_REST_Request( 'GET', '/wc-push-notifications/step-logs/tokens/1' ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_rest_cannot_view', $result->get_error_code() );
+	}
+
+	/**
+	 * A store is most worth reading the step log for once push notifications
+	 * have been switched off, which is often why they were switched off.
+	 *
+	 * @testdox Should still answer while push notifications are disabled.
+	 */
+	public function test_authorize_allows_reading_while_disabled(): void {
+		add_filter( 'pre_option_woocommerce_feature_push_notifications_enabled', fn() => 'no' );
+
+		$server = $this->create_rest_server_with_routes( array( array( $this->sut, 'register_routes' ) ), true );
+
+		$this->assertContains( '/wc-push-notifications/step-logs/site', array_keys( $server->get_routes( 'wc-push-notifications' ) ) );
+
+		remove_filter( 'pre_option_woocommerce_feature_push_notifications_enabled', fn() => 'no' );
+	}
+
+	/**
+	 * @testdox Should pass the requested range through and default it to the last 48 hours ending now.
+	 */
+	public function test_routes_pass_the_range_to_the_query(): void {
+		$server = $this->create_authorized_server();
+		$answer = array(
+			'rows'         => array(),
+			'counts'       => array(),
+			'covered_from' => 100,
+			'logging'      => array(),
+		);
+
+		$this->query->expects( $this->once() )
+			->method( 'for_notification' )
+			->with( 'store_order', 42, 100, 200 )
+			->willReturn( $answer );
+		$this->query->expects( $this->once() )
+			->method( 'for_token' )
+			->with(
+				4412,
+				$this->callback( fn( int $from ) => abs( $from - ( time() - StepLogRestController::DEFAULT_RANGE_SECONDS ) ) <= 2 ),
+				$this->callback( fn( int $to ) => abs( $to - time() ) <= 2 )
+			)
+			->willReturn( $answer );
+		$this->query->expects( $this->once() )
+			->method( 'for_user' )
+			->with( 7, 100, 200 )
+			->willReturn( $answer );
+		$this->query->expects( $this->once() )
+			->method( 'for_site' )
+			->with( 100, 200 )
+			->willReturn( $answer );
+
+		$this->assertSame(
+			$answer,
+			$this->dispatch_as_wpcom(
+				$server,
+				'/wc-push-notifications/step-logs/notifications/store_order/42',
+				array(
+					'from' => 100,
+					'to'   => 200,
+				)
+			)
+		);
+		$this->assertSame( $answer, $this->dispatch_as_wpcom( $server, '/wc-push-notifications/step-logs/tokens/4412' ) );
+		$this->assertSame(
+			$answer,
+			$this->dispatch_as_wpcom(
+				$server,
+				'/wc-push-notifications/step-logs/users/7',
+				array(
+					'from' => 100,
+					'to'   => 200,
+				)
+			)
+		);
+		$this->assertSame(
+			$answer,
+			$this->dispatch_as_wpcom(
+				$server,
+				'/wc-push-notifications/step-logs/site',
+				array(
+					'from' => 100,
+					'to'   => 200,
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should clamp a range longer than the maximum, so one bad parameter cannot stall the request.
+	 */
+	public function test_an_over_long_range_is_clamped(): void {
+		$server = $this->create_authorized_server();
+
+		$this->query->expects( $this->once() )
+			->method( 'for_site' )
+			->with(
+				$this->callback(
+					fn( int $from ) => abs( $from - ( time() - StepLogRestController::MAX_RANGE_SECONDS ) ) <= 2
+				),
+				$this->anything(),
+				$this->anything()
+			)
+			->willReturn(
+				array(
+					'rows'         => array(),
+					'counts'       => array(),
+					'covered_from' => 0,
+					'logging'      => array(),
+				)
+			);
+
+		$this->dispatch_as_wpcom( $server, '/wc-push-notifications/step-logs/site', array( 'from' => 1 ) );
+	}
+
+	/**
+	 * @testdox Should reject an unknown notification type.
+	 */
+	public function test_rejects_an_unknown_type(): void {
+		$server = $this->create_authorized_server();
+
+		$response = $server->dispatch( new WP_REST_Request( 'GET', '/wc-push-notifications/step-logs/notifications/unknown_type/42' ) );
+
+		$this->assertSame( 400, $response->get_status() );
+	}
+
+	/**
+	 * Dispatches a request and returns the response data.
+	 *
+	 * @param \WP_REST_Server $server The server.
+	 * @param string          $route  The route.
+	 * @param array           $params Query parameters.
+	 * @return array
+	 */
+	private function dispatch_as_wpcom( \WP_REST_Server $server, string $route, array $params = array() ): array {
+		$request = new WP_REST_Request( 'GET', $route );
+		$request->set_query_params( $params );
+
+		$response = $server->dispatch( $request );
+
+		$this->assertSame( 200, $response->get_status() );
+
+		return $response->get_data();
+	}
+
+	/**
+	 * Creates a server whose step log routes accept every request, since a
+	 * Jetpack blog token signature cannot be produced in a unit test.
+	 *
+	 * @return \WP_REST_Server
+	 */
+	private function create_authorized_server(): \WP_REST_Server {
+		$controller = new class() extends StepLogRestController {
+			// phpcs:ignore Squiz.Commenting.FunctionComment.Missing
+			public function authorize_as_from_wpcom( WP_REST_Request $request ) {
+				return true;
+			}
+		};
+		$controller->init( $this->query );
+
+		return $this->create_rest_server_with_routes( array( array( $controller, 'register_routes' ) ), true );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
index 159617105d9..312de3e4d12 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
@@ -778,6 +778,57 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$this->assertCount( 1, $data_store->get_tokens_for_roles( array( 'administrator' ) ) );
 	}

+	/**
+	 * @testdox Should return only the given user's token IDs, most recently modified first.
+	 */
+	public function test_get_token_ids_for_user_returns_only_that_users_tokens(): void {
+		$admin_id   = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		$manager_id = $this->factory->user->create( array( 'role' => 'shop_manager' ) );
+		$data_store = new PushTokensDataStore();
+		$first      = $data_store->create( $this->token_data_for( $admin_id ) );
+		$data_store->create( $this->token_data_for( $manager_id ) );
+		$second = $data_store->create( $this->token_data_for( $admin_id ) );
+		$this->set_token_dates( $first->get_id(), '2026-01-01 00:00:00', '2026-01-01 00:00:00' );
+		$this->set_token_dates( $second->get_id(), '2026-01-01 00:00:00', '2026-02-01 00:00:00' );
+
+		$this->assertSame( array( $second->get_id(), $first->get_id() ), $data_store->get_token_ids_for_user( $admin_id ) );
+		$this->assertSame( array(), $data_store->get_token_ids_for_user( 999999 ) );
+	}
+
+	/**
+	 * @testdox Should count every token whatever the owner's role.
+	 */
+	public function test_count_tokens_counts_every_token(): void {
+		$admin_id      = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		$subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+		$data_store    = new PushTokensDataStore();
+
+		$this->assertSame( 0, $data_store->count_tokens() );
+
+		$data_store->create( $this->token_data_for( $admin_id ) );
+		$data_store->create( $this->token_data_for( $subscriber_id ) );
+
+		$this->assertSame( 2, $data_store->count_tokens() );
+	}
+
+	/**
+	 * Builds valid creation data for a token owned by the given user.
+	 *
+	 * @param int $user_id The owning user.
+	 * @return array
+	 */
+	private function token_data_for( int $user_id ): array {
+		return array(
+			'user_id'       => $user_id,
+			'token'         => 'token_' . wp_rand(),
+			'platform'      => PushToken::PLATFORM_APPLE,
+			'device_uuid'   => 'device-' . wp_rand(),
+			'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+			'device_locale' => 'en_US',
+			'metadata'      => array( 'app_version' => '1.0' ),
+		);
+	}
+
 	/**
 	 * @testdox Should return tokens for users with matching roles.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/StepLogFileReaderTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/StepLogFileReaderTest.php
new file mode 100644
index 00000000000..a45617f26dd
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/StepLogFileReaderTest.php
@@ -0,0 +1,235 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\DataStores;
+
+use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Internal\Admin\Logging\LogHandlerFileV2;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\StepLogFileReader;
+use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the StepLogFileReader class.
+ */
+class StepLogFileReaderTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var StepLogFileReader
+	 */
+	private $sut;
+
+	/**
+	 * The real file handler used to write fixtures.
+	 *
+	 * @var LogHandlerFileV2
+	 */
+	private $handler;
+
+	/**
+	 * A temporary log folder, so the tests never touch the site's own log files.
+	 *
+	 * @var string
+	 */
+	private $log_directory;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->log_directory = trailingslashit( sys_get_temp_dir() ) . 'wc-step-log-' . wp_generate_uuid4() . '/';
+		wp_mkdir_p( $this->log_directory );
+		Constants::set_constant( 'WC_LOG_DIR_CUSTOM', true );
+		Constants::set_constant( 'WC_LOG_DIR', $this->log_directory );
+
+		$this->sut     = new StepLogFileReader();
+		$this->handler = new LogHandlerFileV2();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		try {
+			Constants::clear_single_constant( 'WC_LOG_DIR_CUSTOM' );
+			Constants::clear_single_constant( 'WC_LOG_DIR' );
+			FilesystemUtil::get_wp_filesystem_direct()->rmdir( $this->log_directory, true );
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * @testdox Should read back lines written under a source, in time order, with their context decoded.
+	 */
+	public function test_read_returns_lines_for_the_source_in_time_order(): void {
+		$now = time();
+		$this->handler->handle( $now - 10, 'info', 'Recipients: resolved', $this->context( 'push-token-4412', 'recipients', 'resolved' ) );
+		$this->handler->handle( $now - 20, 'info', 'Token excluded: notifications off', $this->context( 'push-token-4412', 'token_excluded', 'notifications_off' ) );
+		$this->handler->handle( $now - 5, 'info', 'Triggered: queued', $this->context( 'push-token-9999', 'triggered', 'queued' ) );
+
+		$rows = $this->sut->read( 'push-token-4412', $now - DAY_IN_SECONDS, $now )['rows'];
+
+		$this->assertCount( 2, $rows );
+		$this->assertSame( 'token_excluded', $rows[0]['context']['step'] );
+		$this->assertSame( 'recipients', $rows[1]['context']['step'] );
+		$this->assertSame( 'info', $rows[1]['level'] );
+		$this->assertSame( 'Recipients: resolved', $rows[1]['message'] );
+		$this->assertSame( $now - 10, $rows[1]['timestamp'] );
+		$this->assertNull( $rows[1]['raw'] );
+	}
+
+	/**
+	 * @testdox Should include the previous day's file when the range starts part way through that day.
+	 */
+	public function test_read_spans_days_from_a_mid_day_start(): void {
+		$yesterday_evening = (int) strtotime( gmdate( 'Y-m-d', time() - DAY_IN_SECONDS ) . 'T23:50:00+00:00' );
+		$this->handler->handle( $yesterday_evening, 'info', 'Triggered: queued', $this->context( 'push-notifications-store-order', 'triggered', 'queued' ) );
+		$this->handler->handle( $yesterday_evening + 900, 'info', 'Loopback started: ok', $this->context( 'push-notifications-store-order', 'loopback_started', 'ok' ) );
+
+		$rows = $this->sut->read( 'push-notifications-store-order', $yesterday_evening - 60, $yesterday_evening + 3600 )['rows'];
+
+		$this->assertSame( array( 'triggered', 'loopback_started' ), array_column( array_column( $rows, 'context' ), 'step' ) );
+	}
+
+	/**
+	 * @testdox Should stop at the newest day that fills the page, leaving older days unread.
+	 */
+	public function test_read_stops_once_the_page_is_full(): void {
+		$today     = (int) strtotime( gmdate( 'Y-m-d' ) . 'T10:00:00+00:00' );
+		$yesterday = $today - DAY_IN_SECONDS;
+
+		$this->handler->handle( $yesterday, 'info', 'Old one', $this->context( 'push-notifications-store-order', 'triggered', 'queued' ) );
+		$this->handler->handle( $today, 'info', 'New one', $this->context( 'push-notifications-store-order', 'triggered', 'queued' ) );
+		$this->handler->handle( $today + 1, 'info', 'New two', $this->context( 'push-notifications-store-order', 'triggered', 'queued' ) );
+
+		$result = $this->sut->read( 'push-notifications-store-order', $yesterday - HOUR_IN_SECONDS, $today + HOUR_IN_SECONDS, 2 );
+
+		$this->assertSame( array( 'New one', 'New two' ), array_column( $result['rows'], 'message' ) );
+		$this->assertSame(
+			(int) strtotime( gmdate( 'Y-m-d' ) . 'T00:00:00+00:00' ),
+			$result['covered_from'],
+			'Coverage should start at the newest day, since the older one was never opened.'
+		);
+		$this->assertTrue( $result['has_more'], 'The unread day may hold older lines.' );
+	}
+
+	/**
+	 * @testdox Should turn each line into the caller's rows as it reads, rather than collecting them all first.
+	 */
+	public function test_read_expands_each_line_as_it_is_read(): void {
+		$now = time();
+		$this->handler->handle( $now - 60, 'info', 'Recipients: resolved', $this->context( 'push-notifications-store-order', 'recipients', 'resolved' ) );
+
+		$result = $this->sut->read(
+			'push-notifications-store-order',
+			$now - HOUR_IN_SECONDS,
+			$now,
+			100,
+			fn( array $row ) => array_fill( 0, 3, $row )
+		);
+
+		$this->assertCount( 3, $result['rows'] );
+	}
+
+	/**
+	 * @testdox Should leave out lines outside the requested range.
+	 */
+	public function test_read_filters_by_timestamp(): void {
+		$now = time();
+		$this->handler->handle( $now - 7200, 'info', 'Old', $this->context( 'push-token-1', 'dispatched', 'accepted' ) );
+		$this->handler->handle( $now - 60, 'info', 'Recent', $this->context( 'push-token-1', 'dispatched', 'accepted' ) );
+
+		$rows = $this->sut->read( 'push-token-1', $now - 3600, $now )['rows'];
+
+		$this->assertCount( 1, $rows );
+		$this->assertSame( 'Recent', $rows[0]['message'] );
+	}
+
+	/**
+	 * @testdox Should return nothing for a source with no file.
+	 */
+	public function test_read_returns_nothing_for_a_missing_file(): void {
+		$this->assertSame( array(), $this->sut->read( 'push-token-404', time() - DAY_IN_SECONDS, time() )['rows'] );
+	}
+
+	/**
+	 * @testdox Should parse a well-formed line and return the raw text when the context is not JSON.
+	 * @dataProvider provider_lines
+	 *
+	 * @param string     $line     The line.
+	 * @param array|null $expected The expected row, or null.
+	 */
+	public function test_parse_line( string $line, ?array $expected ): void {
+		$this->assertSame( $expected, StepLogFileReader::parse_line( $line ) );
+	}
+
+	/**
+	 * Lines and their parsed form.
+	 *
+	 * @return array
+	 */
+	public function provider_lines(): array {
+		return array(
+			'with context'     => array(
+				'2026-09-15T10:00:00+00:00 INFO Dispatched: accepted CONTEXT: {"step":"dispatched","outcome":"accepted"}' . "\n",
+				array(
+					'timestamp' => 1789466400,
+					'level'     => 'info',
+					'message'   => 'Dispatched: accepted',
+					'context'   => array(
+						'step'    => 'dispatched',
+						'outcome' => 'accepted',
+					),
+					'raw'       => null,
+				),
+			),
+			'without context'  => array(
+				'2026-09-15T10:00:00+00:00 ERROR Something broke',
+				array(
+					'timestamp' => 1789466400,
+					'level'     => 'error',
+					'message'   => 'Something broke',
+					'context'   => null,
+					'raw'       => null,
+				),
+			),
+			'reshaped context' => array(
+				'2026-09-15T10:00:00+00:00 INFO Send: accepted CONTEXT: step=send outcome=accepted',
+				array(
+					'timestamp' => 1789466400,
+					'level'     => 'info',
+					'message'   => 'Send: accepted',
+					'context'   => null,
+					'raw'       => '2026-09-15T10:00:00+00:00 INFO Send: accepted CONTEXT: step=send outcome=accepted',
+				),
+			),
+			'no timestamp'     => array( 'just some text here', null ),
+			'unknown level'    => array( '2026-09-15T10:00:00+00:00 LOUD Send: accepted', null ),
+		);
+	}
+
+	/**
+	 * Builds a step logger context for a fixture line.
+	 *
+	 * @param string $source  The log source.
+	 * @param string $step    The step.
+	 * @param string $outcome The outcome.
+	 * @return array
+	 */
+	private function context( string $source, string $step, string $outcome ): array {
+		return array(
+			'source'      => $source,
+			'identifier'  => '1_store_order_42',
+			'type'        => 'store_order',
+			'resource_id' => 42,
+			'step'        => $step,
+			'outcome'     => $outcome,
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/StepLogTableReaderTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/StepLogTableReaderTest.php
new file mode 100644
index 00000000000..f010e12ce48
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/StepLogTableReaderTest.php
@@ -0,0 +1,158 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\DataStores;
+
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\StepLogTableReader;
+use WC_Log_Handler_DB;
+use WC_Log_Levels;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the StepLogTableReader class.
+ */
+class StepLogTableReaderTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var StepLogTableReader
+	 */
+	private $sut;
+
+	/**
+	 * The real database handler used to write fixtures.
+	 *
+	 * @var WC_Log_Handler_DB
+	 */
+	private $handler;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut     = new StepLogTableReader();
+		$this->handler = new WC_Log_Handler_DB();
+	}
+
+	/**
+	 * @testdox Should read back rows written under a source, in time order, with their context decoded.
+	 */
+	public function test_read_returns_rows_for_the_source(): void {
+		$now = time();
+		$this->handler->handle( $now - 20, 'info', 'Token excluded: notifications off', $this->context( 'push-token-4412', 'token_excluded', 'notifications_off' ) );
+		$this->handler->handle( $now - 15, 'info', 'Other source', $this->context( 'push-token-1', 'dispatched', 'accepted' ) );
+		$this->handler->handle( $now - 10, 'error', 'Dispatched: failed', $this->context( 'push-token-4412', 'dispatched', 'failed' ) );
+
+		$result = $this->sut->read( 'push-token-4412', $now - DAY_IN_SECONDS, $now );
+
+		$this->assertCount( 2, $result['rows'] );
+		$this->assertSame( 'token_excluded', $result['rows'][0]['context']['step'] );
+		$this->assertSame( 'error', $result['rows'][1]['level'] );
+		$this->assertSame( 'Dispatched: failed', $result['rows'][1]['message'] );
+		$this->assertSame( $now - 10, $result['rows'][1]['timestamp'] );
+		$this->assertSame( $now - DAY_IN_SECONDS, $result['covered_from'], 'A table the scan fully covers reports the requested start.' );
+	}
+
+	/**
+	 * @testdox Should leave out rows outside the requested range.
+	 */
+	public function test_read_filters_by_timestamp(): void {
+		$now = time();
+		$this->handler->handle( $now - 7200, 'info', 'Old', $this->context( 'push-token-1', 'dispatched', 'accepted' ) );
+		$this->handler->handle( $now - 60, 'info', 'Recent', $this->context( 'push-token-1', 'dispatched', 'accepted' ) );
+
+		$result = $this->sut->read( 'push-token-1', $now - 3600, $now );
+
+		$this->assertCount( 1, $result['rows'] );
+		$this->assertSame( 'Recent', $result['rows'][0]['message'] );
+	}
+
+	/**
+	 * @testdox Should return nothing when the table is empty.
+	 */
+	public function test_read_returns_nothing_for_an_empty_table(): void {
+		$result = $this->sut->read( 'push-token-404', time() - DAY_IN_SECONDS, time() );
+
+		$this->assertSame( array(), $result['rows'] );
+	}
+
+	/**
+	 * @testdox Should read on into the next window when the page's last second carries on there.
+	 */
+	public function test_read_does_not_stop_partway_through_a_second(): void {
+		$now  = time();
+		$edge = $this->next_free_log_id() + 10;
+		$top  = $edge + StepLogTableReader::WINDOW_SIZE;
+
+		// Three newer lines, then five lines of one second on both sides of the edge between two windows.
+		foreach ( array( $top, $top - 1, $top - 2 ) as $offset => $log_id ) {
+			$this->insert_row( $log_id, $now - 1 - $offset, 'Newer ' . $offset );
+		}
+
+		foreach ( range( $edge - 2, $edge + 2 ) as $log_id ) {
+			$this->insert_row( $log_id, $now - 10, 'Split ' . $log_id );
+		}
+
+		$rows = $this->sut->read( 'push-token-1', $now - HOUR_IN_SECONDS, $now, 5 )['rows'];
+
+		$this->assertCount( 5, array_filter( $rows, fn( array $row ) => $now - 10 === $row['timestamp'] ), 'Every line of the second on the window edge should be read.' );
+	}
+
+	/**
+	 * Returns an ID above every row in the log table, for rows placed by ID.
+	 *
+	 * @return int
+	 */
+	private function next_free_log_id(): int {
+		global $wpdb;
+
+		return (int) $wpdb->get_var( "SELECT COALESCE( MAX(log_id), 0 ) FROM {$wpdb->prefix}woocommerce_log" ) + 1; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+	}
+
+	/**
+	 * Writes one `push-token-1` row at a chosen ID, as the database handler would.
+	 *
+	 * @param int    $log_id    The row ID.
+	 * @param int    $timestamp The row's timestamp.
+	 * @param string $message   The message.
+	 * @return void
+	 */
+	private function insert_row( int $log_id, int $timestamp, string $message ): void {
+		global $wpdb;
+
+		$wpdb->insert(
+			$wpdb->prefix . 'woocommerce_log',
+			array(
+				'log_id'    => $log_id,
+				'timestamp' => gmdate( 'Y-m-d H:i:s', $timestamp ),
+				'level'     => WC_Log_Levels::get_level_severity( 'info' ),
+				'source'    => 'push-token-1',
+				'message'   => $message,
+			),
+			array( '%d', '%s', '%d', '%s', '%s' )
+		);
+	}
+
+	/**
+	 * Builds a step logger context for a fixture row.
+	 *
+	 * @param string $source  The log source.
+	 * @param string $step    The step.
+	 * @param string $outcome The outcome.
+	 * @return array
+	 */
+	private function context( string $source, string $step, string $outcome ): array {
+		return array(
+			'source'      => $source,
+			'identifier'  => '1_store_order_42',
+			'type'        => 'store_order',
+			'resource_id' => 42,
+			'step'        => $step,
+			'outcome'     => $outcome,
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/StepLogQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/StepLogQueryTest.php
new file mode 100644
index 00000000000..6dbc4f37b60
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/StepLogQueryTest.php
@@ -0,0 +1,586 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Services;
+
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\StepLogFileReader;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\StepLogTableReader;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\StepLogQuery;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the StepLogQuery class.
+ */
+class StepLogQueryTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var StepLogQuery
+	 */
+	private $sut;
+
+	/**
+	 * Mock file reader.
+	 *
+	 * @var StepLogFileReader|\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private $file_reader;
+
+	/**
+	 * Mock table reader.
+	 *
+	 * @var StepLogTableReader|\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private $table_reader;
+
+	/**
+	 * Mock data store.
+	 *
+	 * @var PushTokensDataStore|\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private $data_store;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->file_reader  = $this->createMock( StepLogFileReader::class );
+		$this->table_reader = $this->createMock( StepLogTableReader::class );
+		$this->data_store   = $this->createMock( PushTokensDataStore::class );
+		$this->data_store->method( 'count_tokens' )->willReturn( 3 );
+
+		$step_logger = new NotificationStepLogger();
+
+		$this->sut = new StepLogQuery();
+		$this->sut->init( $this->file_reader, $this->table_reader, $this->data_store, $step_logger );
+	}
+
+	/**
+	 * @testdox Should read the type's journey file and the module's error source, keeping only the resource's rows.
+	 */
+	public function test_for_notification_reads_both_sources_and_filters_by_resource(): void {
+		$this->file_reader_returns(
+			array(
+				array(
+					'push-notifications-store-order',
+					100,
+					200,
+					array(
+						$this->row( 150, 'store_order', 42, 'triggered', 'queued' ),
+						$this->row( 151, 'store_order', 43, 'triggered', 'queued' ),
+					),
+				),
+				array(
+					'push-suppressed',
+					100,
+					200,
+					array(
+						$this->row( 155, 'store_order', 42, 'token_excluded', 'notifications_off', 'info', 4412, 7 ),
+						$this->row( 156, 'store_order', 43, 'token_excluded', 'notifications_off', 'info', 4413, 8 ),
+					),
+				),
+				array(
+					'push_notifications',
+					100,
+					200,
+					array(
+						$this->row( 160, 'store_order', 42, 'dispatched', 'failed', 'error' ),
+					),
+				),
+			)
+		);
+
+		$result = $this->sut->for_notification( 'store_order', 42, 100, 200, 100 );
+
+		$this->assertCount( 3, $result['rows'] );
+		$this->assertSame( 'error', $result['rows'][0]['level'] );
+		$this->assertSame( gmdate( 'c', 160 ), $result['rows'][0]['timestamp'] );
+		$this->assertSame( 'token_excluded', $result['rows'][1]['context']['step'] );
+		$this->assertSame( 'triggered', $result['rows'][2]['context']['step'] );
+		$this->assertNull( $result['next_to'] );
+		$this->assertEquals(
+			array(
+				'triggered:queued'                 => 1,
+				'token_excluded:notifications_off' => 1,
+				'dispatched:failed'                => 1,
+			),
+			$result['counts']
+		);
+		$this->assertSame( 100, $result['covered_from'] );
+	}
+
+	/**
+	 * @testdox Should read every notification type, the suppressed source and the error source, and no source per token.
+	 */
+	public function test_for_site_reads_every_source_the_store_holds(): void {
+		$read = array();
+		$this->file_reader->method( 'read' )->willReturnCallback(
+			function ( string $source, int $from ) use ( &$read ): array {
+				$read[] = $source;
+
+				return array(
+					'rows'         => array(),
+					'covered_from' => $from,
+					'has_more'     => false,
+				);
+			}
+		);
+
+		$this->sut->for_site( 100, 200, 100 );
+
+		$this->assertEqualsCanonicalizing(
+			array(
+				'push-notifications-store-order',
+				'push-notifications-store-review',
+				'push-notifications-store-stock',
+				'push-suppressed',
+				'push_notifications',
+			),
+			$read
+		);
+	}
+
+	/**
+	 * @testdox Should find one token named inside the notification lines and return a row for it.
+	 */
+	public function test_for_token_finds_the_token_named_in_the_aggregate_lines(): void {
+		$this->reader_rows_from(
+			function ( string $source ) {
+				if ( 'push-notifications-store-order' === $source ) {
+					return array(
+						$this->aggregate_row( 180, 'store_order', 42, 'recipients', 'resolved', array( 'token_ids' => array( 4412, 9999 ) ) ),
+						$this->aggregate_row( 170, 'store_order', 42, 'dispatched', 'invalid_token', array( 'invalid_token_ids' => array( 9999 ) ) ),
+					);
+				}
+
+				if ( 'push-suppressed' === $source ) {
+					return array(
+						$this->aggregate_row(
+							150,
+							'store_order',
+							41,
+							'token_excluded',
+							'notifications_off',
+							array(
+								'excluded_tokens' => array(
+									'notifications_off' => array( 4412 ),
+									'order_missing'     => array( 9999 ),
+								),
+							)
+						),
+					);
+				}
+
+				return array();
+			}
+		);
+
+		$result = $this->sut->for_token( 4412, 100, 200, 100 );
+
+		$this->assertSame( array( gmdate( 'c', 180 ), gmdate( 'c', 150 ) ), array_column( $result['rows'], 'timestamp' ) );
+		$this->assertSame( 'token_included', $result['rows'][0]['context']['step'] );
+		$this->assertSame( 4412, $result['rows'][0]['context']['token_id'] );
+		$this->assertArrayNotHasKey( 'token_ids', $result['rows'][0]['context'] );
+		$this->assertSame( 'token_excluded', $result['rows'][1]['context']['step'] );
+		$this->assertSame( 'notifications_off', $result['rows'][1]['context']['outcome'] );
+	}
+
+	/**
+	 * @testdox Should return a row per token the user owns when one line names several of them.
+	 */
+	public function test_for_user_returns_a_row_per_token_named(): void {
+		$this->data_store->method( 'get_token_ids_for_user' )->with( 7 )->willReturn( array( 11, 12 ) );
+		$this->reader_rows_from(
+			function ( string $source ) {
+				if ( 'push-notifications-store-order' === $source ) {
+					return array(
+						$this->aggregate_row( 180, 'store_order', 42, 'recipients', 'resolved', array( 'token_ids' => array( 11, 12, 99 ) ) ),
+					);
+				}
+
+				return array();
+			}
+		);
+
+		$result = $this->sut->for_user( 7, 100, 200, 100 );
+
+		$this->assertCount( 2, $result['rows'] );
+		$this->assertSame( array( 11, 12 ), array_column( array_column( $result['rows'], 'context' ), 'token_id' ) );
+	}
+
+	/**
+	 * @testdox Should include the batch loopback line covering this notification, which carries no identity of its own.
+	 */
+	public function test_for_notification_includes_the_batch_line_covering_it(): void {
+		$batch_line = array(
+			'timestamp' => 150,
+			'level'     => 'info',
+			'message'   => 'Loopback requested: ok',
+			'context'   => array(
+				'step'          => 'loopback_requested',
+				'outcome'       => 'ok',
+				'batch_size'    => 2,
+				'notifications' => array(
+					array(
+						'type'        => 'store_order',
+						'resource_id' => 42,
+					),
+					array(
+						'type'        => 'store_order',
+						'resource_id' => 43,
+					),
+				),
+			),
+			'raw'       => null,
+		);
+
+		$this->reader_rows_from( fn( string $source ) => 'push_notifications' === $source ? array( $batch_line ) : array() );
+
+		$this->assertCount( 1, $this->sut->for_notification( 'store_order', 42, 100, 200, 100 )['rows'] );
+		$this->assertCount( 0, $this->sut->for_notification( 'store_order', 99, 100, 200, 100 )['rows'] );
+	}
+
+	/**
+	 * @testdox Should order lines sharing a second by the lifecycle, not by the order they were written.
+	 */
+	public function test_lines_sharing_a_second_are_ordered_by_the_lifecycle(): void {
+		$this->file_reader_returns(
+			array(
+				array(
+					'push-notifications-store-order',
+					100,
+					200,
+					array(
+						$this->row( 150, 'store_order', 42, 'loopback_started', 'ok' ),
+						$this->row( 150, 'store_order', 42, 'dispatched', 'accepted' ),
+						$this->row( 150, 'store_order', 42, 'recipients', 'resolved' ),
+						$this->row( 150, 'store_order', 42, 'loopback_requested', 'ok' ),
+					),
+				),
+				array( 'push-suppressed', 100, 200, array() ),
+				array( 'push_notifications', 100, 200, array() ),
+			)
+		);
+
+		$result = $this->sut->for_notification( 'store_order', 42, 100, 200, 100 );
+
+		$this->assertSame(
+			array( 'dispatched', 'recipients', 'loopback_started', 'loopback_requested' ),
+			array_column( array_column( $result['rows'], 'context' ), 'step' )
+		);
+	}
+
+	/**
+	 * @testdox Should return one page newest first, with a cursor one second before the oldest row kept.
+	 */
+	public function test_a_page_is_capped_and_carries_a_cursor(): void {
+		$this->file_reader_returns(
+			array(
+				array(
+					'push-notifications-store-order',
+					100,
+					200,
+					array(
+						$this->row( 150, 'store_order', 42, 'triggered', 'queued' ),
+						$this->row( 160, 'store_order', 42, 'loopback_requested', 'ok' ),
+						$this->row( 170, 'store_order', 42, 'dispatched', 'accepted' ),
+					),
+				),
+				array( 'push-suppressed', 100, 200, array() ),
+				array( 'push_notifications', 100, 200, array() ),
+			)
+		);
+
+		$result = $this->sut->for_notification( 'store_order', 42, 100, 200, 2 );
+
+		$this->assertSame( array( gmdate( 'c', 170 ), gmdate( 'c', 160 ) ), array_column( $result['rows'], 'timestamp' ) );
+		$this->assertSame( 159, $result['next_to'] );
+		$this->assertEquals(
+			array(
+				'dispatched:accepted'   => 1,
+				'loopback_requested:ok' => 1,
+			),
+			$result['counts']
+		);
+	}
+
+	/**
+	 * @testdox Should carry every row of the page's last second, even past the limit.
+	 */
+	public function test_a_page_never_ends_partway_through_a_second(): void {
+		$this->file_reader_returns(
+			array(
+				array(
+					'push-notifications-store-order',
+					100,
+					200,
+					array(
+						$this->row( 150, 'store_order', 42, 'triggered', 'queued' ),
+						$this->row( 160, 'store_order', 42, 'loopback_requested', 'ok' ),
+						$this->row( 160, 'store_order', 42, 'loopback_started', 'ok' ),
+						$this->row( 160, 'store_order', 42, 'recipients', 'resolved' ),
+					),
+				),
+				array( 'push-suppressed', 100, 200, array() ),
+				array( 'push_notifications', 100, 200, array() ),
+			)
+		);
+
+		$result = $this->sut->for_notification( 'store_order', 42, 100, 200, 2 );
+
+		$this->assertCount( 3, $result['rows'] );
+		$this->assertSame( array( 160, 160, 160 ), array_map( fn( array $row ) => (int) strtotime( $row['timestamp'] ), $result['rows'] ) );
+		$this->assertSame( 159, $result['next_to'] );
+	}
+
+	/**
+	 * @testdox Should report no cursor when the range holds nothing older.
+	 */
+	public function test_a_page_holding_everything_has_no_cursor(): void {
+		$this->file_reader_returns(
+			array(
+				array(
+					'push-notifications-store-order',
+					100,
+					200,
+					array( $this->aggregate_row( 150, 'store_order', 42, 'recipients', 'resolved', array( 'token_ids' => array( 4412 ) ) ) ),
+				),
+				array( 'push-notifications-store-review', 100, 200, array() ),
+				array( 'push-notifications-store-stock', 100, 200, array() ),
+				array( 'push-suppressed', 100, 200, array() ),
+			)
+		);
+
+		$result = $this->sut->for_token( 4412, 100, 200, 100 );
+
+		$this->assertCount( 1, $result['rows'] );
+		$this->assertNull( $result['next_to'] );
+	}
+
+	/**
+	 * @testdox Should read the table instead of files, and report its coverage, when the store uses the database handler.
+	 */
+	public function test_uses_the_table_reader_for_the_database_handler(): void {
+		add_filter( 'pre_option_woocommerce_logs_default_handler', fn() => 'WC_Log_Handler_DB' );
+
+		$read = 0;
+
+		$this->file_reader->expects( $this->never() )->method( 'read' );
+		$this->reader_rows_from(
+			function ( string $source ) use ( &$read ): array {
+				++$read;
+
+				return 'push-notifications-store-order' === $source
+					? array( $this->aggregate_row( 150, 'store_order', 42, 'recipients', 'resolved', array( 'token_ids' => array( 4412 ) ) ) )
+					: array();
+			},
+			$this->table_reader
+		);
+
+		$result = $this->sut->for_token( 4412, 100, 200, 100 );
+
+		$this->assertCount( 1, $result['rows'] );
+		$this->assertSame( 4, $read, 'Every source a token read covers should be read from the table.' );
+	}
+
+	/**
+	 * @testdox Should return the store's logging state with every answer.
+	 */
+	public function test_returns_the_logging_state(): void {
+		$this->file_reader->method( 'read' )->willReturn(
+			array(
+				'rows'         => array(),
+				'covered_from' => 0,
+				'has_more'     => false,
+			)
+		);
+
+		$state = $this->sut->for_token( 4412, 100, 200, 100 )['logging'];
+
+		$this->assertTrue( $state['logging_enabled'] );
+		$this->assertTrue( $state['push_logging_enabled'] );
+		$this->assertSame( 'none', $state['level_threshold'] );
+		$this->assertSame( 30, $state['retention_period_days'] );
+		$this->assertSame( 3, $state['token_count'] );
+		$this->assertSame( NotificationProcessor::RECIPIENT_ID_CAP, $state['recipient_id_cap'] );
+		$this->assertArrayHasKey( 'default_handler', $state );
+		$this->assertArrayHasKey( 'log_directory_writable', $state );
+	}
+
+	/**
+	 * @testdox Should offer the next page when one reader stopped at the limit, even with no row left over.
+	 */
+	public function test_a_reader_that_stopped_at_the_limit_still_gives_a_cursor(): void {
+		$this->reader_rows_from(
+			function ( string $source ) {
+				return 'push-notifications-store-order' === $source
+					? array(
+						$this->aggregate_row( 180, 'store_order', 42, 'recipients', 'resolved', array( 'token_ids' => array( 4412 ) ) ),
+						$this->aggregate_row( 170, 'store_order', 41, 'recipients', 'resolved', array( 'token_ids' => array( 4412 ) ) ),
+					)
+					: array();
+			},
+			null,
+			array( 'push-notifications-store-order' )
+		);
+
+		$result = $this->sut->for_token( 4412, 100, 200, 2 );
+
+		$this->assertCount( 2, $result['rows'] );
+		$this->assertSame( 169, $result['next_to'], 'The cursor should start one second before the oldest row on the page.' );
+	}
+
+	/**
+	 * @testdox Should count a failure once when both its error line and its step line are on the page.
+	 */
+	public function test_a_failure_logged_twice_counts_once(): void {
+		$this->file_reader_returns(
+			array(
+				array( 'push-notifications-store-order', 100, 200, array( $this->row( 160, 'store_order', 42, 'dispatched', 'request_failed' ) ) ),
+				array( 'push_notifications', 100, 200, array( $this->row( 160, 'store_order', 42, 'dispatched', 'request_failed', 'error' ) ) ),
+			)
+		);
+
+		$result = $this->sut->for_notification( 'store_order', 42, 100, 200, 100 );
+
+		$this->assertCount( 2, $result['rows'], 'Both lines should still be returned.' );
+		$this->assertSame( 1, $result['counts']['dispatched:request_failed'] );
+	}
+
+	/**
+	 * Makes a reader answer from a function of source to rows, applying the
+	 * expansion and returning the coverage a real reader now returns.
+	 *
+	 * @param callable        $rows_for_source   Called with a source, returning its rows.
+	 * @param MockObject|null $reader            The reader to stub, defaulting to the file reader.
+	 * @param string[]        $sources_with_more Sources whose reader reports it stopped at the limit.
+	 * @return void
+	 */
+	private function reader_rows_from( callable $rows_for_source, $reader = null, array $sources_with_more = array() ): void {
+		$reader = $reader ?? $this->file_reader;
+
+		$reader->method( 'read' )->willReturnCallback(
+			function ( string $source, int $from, int $to, int $limit = PHP_INT_MAX, ?callable $expand = null ) use ( $rows_for_source, $sources_with_more ): array {
+				$rows = array();
+
+				foreach ( $rows_for_source( $source ) as $row ) {
+					if ( null === $expand ) {
+						$rows[] = $row;
+						continue;
+					}
+
+					foreach ( $expand( $row ) as $expanded ) {
+						$rows[] = $expanded;
+					}
+				}
+
+				return array(
+					'rows'         => $rows,
+					'covered_from' => $from,
+					'has_more'     => in_array( $source, $sources_with_more, true ),
+				);
+			}
+		);
+	}
+
+	/**
+	 * Makes the file reader answer from the same shape willReturnMap took,
+	 * applying the expansion and returning the coverage the real reader now
+	 * returns, since the filtering happens as each line is read.
+	 *
+	 * @param array<int, array{0: string, 1: int, 2: int, 3: array}> $map Source, from, to and the rows to return.
+	 * @return void
+	 */
+	private function file_reader_returns( array $map ): void {
+		$by_source = array();
+
+		foreach ( $map as $entry ) {
+			$by_source[ $entry[0] ] = $entry[3];
+		}
+
+		$this->file_reader->method( 'read' )->willReturnCallback(
+			function ( string $source, int $from, int $to, int $limit = PHP_INT_MAX, ?callable $expand = null ) use ( $by_source ): array {
+				$rows = array();
+
+				foreach ( $by_source[ $source ] ?? array() as $row ) {
+					if ( null === $expand ) {
+						$rows[] = $row;
+						continue;
+					}
+
+					foreach ( $expand( $row ) as $expanded ) {
+						$rows[] = $expanded;
+					}
+				}
+
+				return array(
+					'rows'         => $rows,
+					'covered_from' => $from,
+					'has_more'     => false,
+				);
+			}
+		);
+	}
+
+	/**
+	 * Builds a row whose context names tokens in arrays, as the aggregated
+	 * lines do.
+	 *
+	 * @param int    $timestamp   The line's timestamp.
+	 * @param string $type        The notification type.
+	 * @param int    $resource_id The order, comment or product ID.
+	 * @param string $step        The step name.
+	 * @param string $outcome     The outcome.
+	 * @param array  $extra       Context fields naming the tokens.
+	 * @return array
+	 */
+	private function aggregate_row( int $timestamp, string $type, int $resource_id, string $step, string $outcome, array $extra ): array {
+		$row            = $this->row( $timestamp, $type, $resource_id, $step, $outcome );
+		$row['context'] = array_merge( $row['context'], $extra );
+
+		return $row;
+	}
+
+	/**
+	 * Builds a parsed row as the readers return it.
+	 *
+	 * @param int      $timestamp   The line's timestamp.
+	 * @param string   $type        The notification type.
+	 * @param int      $resource_id The order, comment or product ID.
+	 * @param string   $step        The step name.
+	 * @param string   $outcome     The outcome.
+	 * @param string   $level       The log level.
+	 * @param int|null $token_id    The push token post ID, for a per-token row.
+	 * @param int|null $user_id     The user who owns the token.
+	 * @return array
+	 */
+	private function row( int $timestamp, string $type, int $resource_id, string $step, string $outcome, string $level = 'info', ?int $token_id = null, ?int $user_id = null ): array {
+		$context = array(
+			'identifier'  => '1_' . $type . '_' . $resource_id,
+			'type'        => $type,
+			'resource_id' => $resource_id,
+			'step'        => $step,
+			'outcome'     => $outcome,
+		);
+
+		if ( null !== $token_id ) {
+			$context['token_id'] = $token_id;
+			$context['user_id']  = $user_id;
+		}
+
+		return array(
+			'timestamp' => $timestamp,
+			'level'     => $level,
+			'message'   => $step . ': ' . $outcome,
+			'context'   => $context,
+			'raw'       => null,
+		);
+	}
+}