Commit 4e49a68f905 for woocommerce

commit 4e49a68f9056cdfadac4793327708ef5b975006c
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Tue May 5 22:59:26 2026 +0300

    Add review-order endpoint and landing page skeleton (#64483)

    * Add review-order top-level endpoint and landing page skeleton

    Registers a standalone `/review-order/{order_id}/?key={order_key}` URL
    that the Customer Review Request email links to. Auth is by order key
    so guest customers can reach the page without logging in. Form controls
    (star rating, textarea, submit) land in M4.

    The endpoint sits outside the checkout/my-account families on purpose:

    - It is not a checkout sub-mode like order-pay or order-received; the
      customer is reviewing past purchases, not transacting. Living on the
      checkout page would force integration with the checkout block, the
      empty-cart redirect, and the [woocommerce_checkout] shortcode for
      what is fundamentally a post-order action.
    - It is not a my-account endpoint because guest orders must be
      reachable via the order key; my-account is login-gated.

    ## Layout

    `Internal/OrderReviews/` becomes the home for the whole feature:

    - `Scheduler` — Action Scheduler wiring (introduced in PR #64395, here
      refactored to the container's auto-`init()` pattern).
    - `Endpoint` — registers the rewrite rule on `init`, exposes the query
      var, renders the page (or its 404 fallback) on `template_redirect`.
    - `OrderReviews` — single-line bootstrap wrapper. The container resolves
      it, sees `init( Scheduler, Endpoint )`, and chain-resolves both
      sub-services so each one's `init()` fires with hooks registered. New
      sub-services (M4 SubmissionHandler, etc.) join as additional `init()`
      args without touching `class-woocommerce.php`.

    `Internal/Email/` is back to email-engine code only.

    ## Gating

    `Endpoint` runs four checks and silently 404s on any failure (so a
    leaked link cannot disclose order existence):

    1. Order exists.
    2. `$_GET['key'] === $order->get_order_key()` (constant-time compare).
    3. Order status ∈ filterable eligible set
       (`woocommerce_review_order_eligible_statuses`, default `completed`).
    4. Logged-in user (if any) owns the order. Guests with the right key
       still pass.

    The route check is the second-line defence behind the scheduler's
    unschedule-on-refund/cancel/trash/delete: the email is already in the
    inbox when the order moves out of `completed`, so the click also has
    to be blocked.

    ## Helper + email

    - `wc_get_review_order_url()` (in `wc-page-functions.php`) delegates
      to `Endpoint::get_url()` so callers outside the email continue to
      work.
    - `WC_Email_Customer_Review_Request::get_review_order_url()` swaps to
      the helper.

    ## Template

    `templates/order/customer-review-order.php` follows Figma node 58:647
    read-only skeleton: meta line, H1, intro, "* Mandatory fields" legend,
    and per-item rows with linked product title, thumbnail, and a
    "Review form coming soon." placeholder. Lives under `order/` (not
    `checkout/`) because it's a post-order action, not a checkout sub-mode.

    ## Tests

    `EndpointTest` covers the helper (URL shape, empty input, filter), the
    query-var registration, and each gating branch.

    Closes #64306 (WOOPLUG-6592).
    Closes #64307 (WOOPLUG-6593).

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Avoid empty parens when order date is unavailable on Review Order page

    Address Copilot review: when an order has no creation date, the meta
    string previously rendered as 'Order #123 ()'. Use a separate
    translatable string in that case.

    * Move OrderReviews bootstrap to hooks-on-instantiation section

    Address Copilot review: the OrderReviews wrapper relies on container
    auto-init rather than calling register(), so it belongs with the other
    hooks-on-instantiation services rather than under the register() block.

    * Remove forward-looking M-numbered milestone references from template

    Each PR should land as a self-contained change without referring to
    unmerged work. Stripped: 'land in M4', '(M5) or that are otherwise
    ineligible', and the 'Review form coming soon' placeholder copy.

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Provide a minimal 404 fallback when the theme has no 404.php

    If get_query_template('404') returns empty, render an inline minimal
    404 document so the response body is not empty.

    * Don't bypass routing on order id 0; drop the duplicate nocache_headers

    empty($wp->query_vars[QUERY_VAR]) treats the literal '0' as empty, so
    /review-order/0/ would skip our handler entirely and fall through to
    normal WP routing. Use isset() instead and let render() 404 on the
    absint() result.

    Also drop a duplicate nocache_headers() call in render_404()'s theme-
    template-missing fallback path.

    * Route review-order through a WC-managed page + shortcode

    The earlier standalone rewrite called get_header() / get_footer() to wrap
    the body. Those theme-API entry points only resolve to header.php /
    footer.php on classic themes; on block themes (Twenty Twenty-Five and
    every other block theme) the calls render nothing useful, leaving the
    response without an <html><head><body> shell.

    Match the pay-for-order pattern instead. Seed a WP page with slug
    review-order in wc_create_pages(), put [woocommerce_review_order] in its
    content, and rewrite /review-order/{id}/ to that page id with the order
    id as a query var. The active theme renders the page through its normal
    template hierarchy on both classic and block themes; the shortcode only
    echoes the form body inside the_content. Auth gating moves to a
    template_redirect hook that 404s before rendering, so the response
    status is set before any output starts.

    * Address review findings on the review-order endpoint

    Three issues from the local review pass:

    1. The new Review Order page was only added in `wc_create_pages()`, which
       only runs on fresh installs. Existing stores upgrading would never get
       the page or the `woocommerce_review_order_page_id` option, so
       `Endpoint::add_rewrite_rule()` would have nothing to bind to and the
       helper would generate links that don't resolve. Add a 10.9.0 update
       callback that seeds just the review_order page on upgrade and
       re-flushes the rewrite rules, mirroring the 5.6 refund-returns
       migration.

    2. `Endpoint::get_url()` blindly appended the order id to the page
       permalink. On plain-permalink stores `get_permalink()` is
       `/?page_id=NNN`, so the helper produced `/?page_id=NNN/99/?key=...`
       instead of a routable URL. Branch on whether the permalink already
       has a query string and emit `add_query_arg()` form for the plain case
       and the path-segment form for pretty permalinks; fall back to a
       home_url-based query-var URL when the page has been deleted entirely.

    3. The rewrite rule was built from `$page->post_name` only, but the
       helper uses the full page permalink. If the Review Order page was
       ever moved under a parent (hierarchical pages), generated links would
       look like `/parent/review-order/{id}/` while the rewrite would only
       match `^review-order/...`. Use `wp_make_link_relative` on the full
       permalink so the rewrite tracks the actual URL structure.

    * Defer the rewrite flush and harden get_url for the page-missing case

    The 10.9.0 upgrade callback runs from `WC_Install::check_version()` on
    init priority 5, so calling `flush_rewrite_rules()` directly inside it
    persists the rules table before `Endpoint::add_rewrite_rule()` (init
    priority 10) has a chance to register the new `/review-order/{id}/`
    rule. Set a one-shot option flag instead and run the flush from
    `wp_loaded`, which fires after every init callback.

    Also tighten `get_url()` so it returns '' when the host page is missing
    rather than emitting a URL that would land on a blank front page.

    Test fixture: the helper test now seeds a Review Order page in setUp
    because the test transaction rolls the post back between runs while the
    page-id option persists, leaving a stale id behind.

    * Skip emitting a review URL when the host page isn't published

    Match the publish guard on `add_rewrite_rule()` inside `get_url()` so
    admins who draft or unpublish the Review Order page don't get
    review-request emails pointing at a URL the rewrite no longer routes.

    * Revert "Skip emitting a review URL when the host page isn't published"

    This reverts commit 6a333f1a642a449681bf84c7ba3b1271f88809cf.

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Redirect to home when the review-order page is hit without an order id

    A direct visit to the WC-managed Review Order page slug (e.g.
    /review-order/) hits the page but the shortcode renders nothing,
    leaving the customer on a chrome-only blank page. Send them to the
    home page instead so the URL doesn't dead-end.

    * Cast filtered eligible items to array before iterating

    woocommerce_review_order_eligible_items is extension-facing; a buggy
    callback returning anything other than an array would otherwise warn
    on the foreach below.

    * Rename review-order page update routine to the 10.8 slot

    The Customer Review Request feature ships in the 10.8 release, not
    10.9. Rename wc_update_1090_create_review_order_page to
    wc_update_1080_create_review_order_page and move it to the 10.8.0
    $db_updates entry so existing stores get the page on the 10.8 upgrade
    rather than waiting for 10.9.

    * Keep the Review Order page out of auto-add nav menus, fix version refs

    - WP's '_wp_auto_add_pages_to_menu' on transition_post_status would add
      the seeded Review Order page to any menu with 'Auto add new top-level
      pages' enabled. The page is reachable only via the tokenised email
      link; nobody navigates to it from a site menu. Detach the auto-add
      callback for our specific page slug, then restore it for everything
      else.

    - Drop two stale '10.9.0' references in the maybe_flush_pending_rewrite
      docblock; this whole feature ships in 10.8.0.

    * Drop OrderReviews wrapper; resolve sub-services from class-woocommerce

    Once the wrapper became a thin pass-through that just calls
    $container->get() for each sub-service, it stopped earning its keep —
    no shared state, no shared filter, no logic beyond the resolution. List
    the sub-services directly in class-woocommerce.php instead, matching
    how every other Internal/* feature is bootstrapped.

    Also tighten Endpoint::skip_auto_menu_for_self() so it identifies the
    seeded page by stored option id, slug prefix, and shortcode body
    instead of an exact 'review-order' string match — WP appends -2/-3
    when the slug is already taken.

    And scope Endpoint::gate_request() to the WC-managed Review Order page
    so a stray review-order query var on some other page can't trigger the
    auth path and 404 an unrelated route.

    * Hide the Review Order page from get_pages() so block themes drop it too

    The earlier transition_post_status guard kept the page out of classic
    nav menus that have 'Auto add new top-level pages' enabled, but block
    themes don't use that mechanism — the core/page-list block calls
    get_pages() and renders every published top-level page it finds. Filter
    the seeded page out of get_pages() results so block themes (e.g.
    Twenty Twenty-Five) don't surface it in their navigation alongside
    Cart / Checkout / My account. The page stays accessible via the
    tokenised email link and remains visible in the admin Pages list,
    which uses WP_Query rather than get_pages().

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/64483-wooplug-6592-6593-review-order-endpoint-and-template b/plugins/woocommerce/changelog/64483-wooplug-6592-6593-review-order-endpoint-and-template
new file mode 100644
index 00000000000..6dcbeb3fe19
--- /dev/null
+++ b/plugins/woocommerce/changelog/64483-wooplug-6592-6593-review-order-endpoint-and-template
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a `/review-order/{id}/?key={order_key}` URL and a tokenized, read-only landing page for the Customer Review Request feature, plus the public `wc_get_review_order_url()` helper. The page is read-only; form controls land in a follow-up.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index b4a533fe1c4..51db846e1af 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -329,6 +329,7 @@ class WC_Install {
 			'wc_update_1080_migrate_analytics_import_option',
 			'wc_update_1080_slim_orders_meta_key_index',
 			'wc_update_1080_backfill_email_template_sync_meta',
+			'wc_update_1080_create_review_order_page',
 		),
 	);

@@ -1157,6 +1158,11 @@ class WC_Install {
 					'title'   => _x( 'My account', 'Page title', 'woocommerce' ),
 					'content' => '<!-- wp:shortcode -->[' . $my_account_shortcode . ']<!-- /wp:shortcode -->',
 				),
+				'review_order'   => array(
+					'name'    => _x( 'review-order', 'Page slug', 'woocommerce' ),
+					'title'   => _x( 'Review your order', 'Page title', 'woocommerce' ),
+					'content' => '<!-- wp:shortcode -->[woocommerce_review_order]<!-- /wp:shortcode -->',
+				),
 				'refund_returns' => array(
 					'name'        => _x( 'refund_returns', 'Page slug', 'woocommerce' ),
 					'title'       => _x( 'Refund and Returns Policy', 'Page title', 'woocommerce' ),
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 862d5621e0b..36539c18016 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -376,6 +376,8 @@ final class WooCommerce {
 		$container->get( ProductVersionStringInvalidator::class );
 		$container->get( OrdersVersionStringInvalidator::class );
 		$container->get( TaxRateVersionStringInvalidator::class );
+		$container->get( Automattic\WooCommerce\Internal\OrderReviews\Scheduler::class );
+		$container->get( Automattic\WooCommerce\Internal\OrderReviews\Endpoint::class );

 		// Feature flags.
 		if ( Constants::is_true( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' ) ) {
@@ -399,7 +401,6 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\ProductFeed\ProductFeed::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\PushNotifications\PushNotifications::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Orders\PointOfSaleEmailHandler::class )->register();
-		$container->get( Automattic\WooCommerce\Internal\OrderReviews\Scheduler::class )->register();

 		// Classes inheriting from RestApiControllerBase.
 		$container->get( Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingRestController::class )->register();
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-review-request.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-review-request.php
index f0ff16d1f36..f286e5bf367 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-review-request.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-review-request.php
@@ -138,30 +138,11 @@ if ( ! class_exists( 'WC_Email_Customer_Review_Request', false ) ) :
 		/**
 		 * Get the URL of the per-order Review Order page for this email's order.
 		 *
-		 * Mirrors the pay-for-order URL shape. `wc_get_endpoint_url()` is used so
-		 * plain-permalink stores get a valid query-arg URL rather than an invalid
-		 * concatenation. The endpoint itself is registered in a later milestone.
-		 *
 		 * @since  10.8.0
 		 * @return string
 		 */
 		public function get_review_order_url() {
-			if ( ! ( $this->object instanceof WC_Order ) ) {
-				return '';
-			}
-
-			$endpoint_url = wc_get_endpoint_url( 'review-order', (string) $this->object->get_id(), wc_get_checkout_url() );
-			$url          = add_query_arg( 'key', $this->object->get_order_key(), $endpoint_url );
-
-			/**
-			 * Filter the Review Order URL that the review-request email links to.
-			 *
-			 * @param string   $url   The review-order URL.
-			 * @param WC_Order $order The order object.
-			 *
-			 * @since 10.8.0
-			 */
-			return (string) apply_filters( 'woocommerce_review_order_url', $url, $this->object );
+			return $this->object instanceof WC_Order ? wc_get_review_order_url( $this->object ) : '';
 		}

 		/**
diff --git a/plugins/woocommerce/includes/wc-page-functions.php b/plugins/woocommerce/includes/wc-page-functions.php
index de3dfd511d4..a0d433e280d 100644
--- a/plugins/woocommerce/includes/wc-page-functions.php
+++ b/plugins/woocommerce/includes/wc-page-functions.php
@@ -148,6 +148,25 @@ function wc_get_endpoint_url( $endpoint, $value = '', $permalink = '' ) {
 	return apply_filters( 'woocommerce_get_endpoint_url', $url, $endpoint, $value, $permalink );
 }

+/**
+ * Get the tokenized Review Order URL for the given order.
+ *
+ * Top-level rewrite: returns `/review-order/{id}/?key={order_key}` on pretty
+ * permalinks and `/?review-order={id}&key={order_key}` on plain permalinks.
+ * The order key allows guest customers to reach the page without logging in.
+ *
+ * @since 10.8.0
+ * @param  WC_Order $order Order object.
+ * @return string
+ */
+function wc_get_review_order_url( $order ) {
+	if ( ! $order instanceof WC_Order ) {
+		return '';
+	}
+
+	return \Automattic\WooCommerce\Internal\OrderReviews\Endpoint::get_url( $order );
+}
+
 /**
  * Hide or adjust menu items conditionally.
  *
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 3648be5bb37..5630210607f 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -3511,3 +3511,31 @@ function wc_update_1080_slim_orders_meta_key_index(): void {
 function wc_update_1080_backfill_email_template_sync_meta(): bool {
 	return WCEmailTemplateSyncBackfill::run();
 }
+
+/**
+ * Seeds the Review Order page on existing installs so the rewrite rule and
+ * helper URL work after upgrading to 10.8.0. Mirrors how
+ * `wc_update_560_create_refund_returns_page` backfilled the refund/returns
+ * page when that feature shipped.
+ *
+ * @since 10.8.0
+ *
+ * @return void
+ */
+function wc_update_1080_create_review_order_page(): void {
+	$only_review_order = static function ( array $pages ): array {
+		return array_intersect_key( $pages, array_flip( array( 'review_order' ) ) );
+	};
+
+	add_filter( 'woocommerce_create_pages', $only_review_order );
+
+	WC_Install::create_pages();
+
+	remove_filter( 'woocommerce_create_pages', $only_review_order );
+
+	// `Endpoint::add_rewrite_rule` runs on init:10; this update routine fires
+	// from WC_Install::check_version on init:5, so flushing here would
+	// persist the rules table without the new /review-order/{id}/ rule.
+	// Defer the flush via an option that the endpoint clears on wp_loaded.
+	update_option( 'woocommerce_review_order_flush_rewrite_pending', 'yes' );
+}
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
new file mode 100644
index 00000000000..b628a3f9759
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
@@ -0,0 +1,428 @@
+<?php
+/**
+ * Endpoint class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\OrderReviews;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use WC_Order;
+use WP_Post;
+
+/**
+ * Routes `/review-order/{id}/?key={order_key}` to the WooCommerce-managed
+ * Review Order page and renders the read-only landing page through the
+ * `[woocommerce_review_order]` shortcode.
+ *
+ * The page is intentionally hosted outside the checkout/my-account family:
+ *
+ * - It is not a checkout sub-mode like order-pay or order-received; the
+ *   customer is reviewing past purchases, not transacting.
+ * - It is not a my-account endpoint because the order key is the auth, so
+ *   guest customers must be able to reach it without logging in.
+ *
+ * The route uses the same wp_posts-backed page pattern as the checkout
+ * page so the active theme owns the page chrome (header, footer, sidebar)
+ * on both classic and block themes; the shortcode only renders the form
+ * body inside `the_content`. Any failed gating check renders the theme's
+ * 404 template so a leaked or stale link cannot disclose order existence.
+ *
+ * The container auto-calls `init()` after instantiation, which is where
+ * the WordPress hooks are registered. Resolution is driven by the
+ * `OrderReviews` wrapper that lists this class as an `init()` argument.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 10.8.0
+ */
+class Endpoint {
+
+	/**
+	 * Query var that the rewrite rule sets to the order id.
+	 */
+	public const QUERY_VAR = 'review-order';
+
+	/**
+	 * `wc_get_page_id()` key for the WC-managed Review Order page.
+	 */
+	public const PAGE_KEY = 'review_order';
+
+	/**
+	 * Shortcode tag that renders the page body inside the WC page content.
+	 */
+	public const SHORTCODE = 'woocommerce_review_order';
+
+	/**
+	 * Wire the endpoint into WordPress.
+	 *
+	 * Auto-called by the WC dependency container after instantiation.
+	 *
+	 * @internal
+	 */
+	final public function init(): void {
+		add_action( 'init', array( $this, 'add_rewrite_rule' ) );
+		add_filter( 'query_vars', array( $this, 'add_query_var' ), 0 );
+		add_action( 'template_redirect', array( $this, 'gate_request' ) );
+		add_action( 'wp_loaded', array( $this, 'maybe_flush_pending_rewrite' ) );
+		add_action( 'transition_post_status', array( $this, 'skip_auto_menu_for_self' ), 9, 3 );
+		add_filter( 'get_pages', array( $this, 'exclude_self_from_page_list' ) );
+		add_shortcode( self::SHORTCODE, array( $this, 'render_shortcode' ) );
+	}
+
+	/**
+	 * Hide the Review Order page from `get_pages()` results.
+	 *
+	 * Block themes' `core/page-list` block (and any classic theme using
+	 * `wp_list_pages()`) calls `get_pages()` to populate its list. Without
+	 * this filter the tokenised landing page would appear in the site
+	 * navigation alongside Cart / Checkout / My account, which is wrong:
+	 * the page is reachable only through the per-order email link.
+	 *
+	 * @param \WP_Post[]|mixed $pages Page objects returned by get_pages().
+	 * @return \WP_Post[]|mixed
+	 */
+	public function exclude_self_from_page_list( $pages ) {
+		if ( ! is_array( $pages ) || empty( $pages ) ) {
+			return $pages;
+		}
+		$page_id = (int) wc_get_page_id( self::PAGE_KEY );
+		if ( $page_id <= 0 ) {
+			return $pages;
+		}
+		return array_values(
+			array_filter(
+				$pages,
+				static function ( $page ) use ( $page_id ) {
+					return ! ( $page instanceof \WP_Post ) || (int) $page->ID !== $page_id;
+				}
+			)
+		);
+	}
+
+	/**
+	 * Keep the Review Order page out of nav menus that have "Auto add new
+	 * top-level pages" enabled.
+	 *
+	 * The page is reachable only through the tokenised URL the email sends
+	 * out; nobody navigates to it from a menu, so it should never appear
+	 * there. WP's `_wp_auto_add_pages_to_menu()` runs on
+	 * `transition_post_status` at priority 10. Detach it just before that
+	 * for our specific page, then restore it on priority 11 so other
+	 * transitions are unaffected.
+	 *
+	 * Compares by slug rather than by stored option id so it also fires on
+	 * the very first install — before `woocommerce_review_order_page_id`
+	 * is written.
+	 *
+	 * @param string   $new_status New post status.
+	 * @param string   $old_status Old post status.
+	 * @param \WP_Post $post       Post object.
+	 */
+	public function skip_auto_menu_for_self( $new_status, $old_status, $post ): void {
+		unset( $new_status, $old_status );
+		if ( ! $post instanceof \WP_Post || 'page' !== $post->post_type ) {
+			return;
+		}
+
+		// Identify the page by stored option id (post-install) or by the
+		// shortcode in its content (during install, before the option
+		// exists). Don't compare $post->post_name to 'review-order' alone:
+		// WP appends -2/-3/... if the slug already exists.
+		$stored_id  = (int) get_option( 'woocommerce_review_order_page_id' );
+		$is_by_id   = $stored_id > 0 && $stored_id === (int) $post->ID;
+		$is_by_slug = '' === $post->post_name
+			? false
+			: ( 'review-order' === $post->post_name || 0 === strpos( $post->post_name, 'review-order-' ) );
+		$is_by_body = false !== strpos( (string) $post->post_content, '[' . self::SHORTCODE . ']' );
+		if ( ! $is_by_id && ! $is_by_slug && ! $is_by_body ) {
+			return;
+		}
+
+		remove_action( 'transition_post_status', '_wp_auto_add_pages_to_menu', 10 );
+		add_action(
+			'transition_post_status',
+			static function () {
+				add_action( 'transition_post_status', '_wp_auto_add_pages_to_menu', 10, 3 );
+			},
+			11
+		);
+	}
+
+	/**
+	 * Flush rewrite rules once after the 10.8.0 upgrade installs the
+	 * Review Order page.
+	 *
+	 * The 10.8.0 db update runs on `init` priority 5 and only seeds the
+	 * page; `add_rewrite_rule()` doesn't fire until `init` priority 10, so
+	 * the flush has to happen later. `wp_loaded` runs after every `init`
+	 * callback, which is the earliest safe moment.
+	 */
+	public function maybe_flush_pending_rewrite(): void {
+		if ( 'yes' !== get_option( 'woocommerce_review_order_flush_rewrite_pending' ) ) {
+			return;
+		}
+		flush_rewrite_rules( false );
+		delete_option( 'woocommerce_review_order_flush_rewrite_pending' );
+	}
+
+	/**
+	 * Register the rewrite rule for the review-order endpoint.
+	 *
+	 * Maps `/<page-slug>/{id}/` to the WC-managed Review Order page so the
+	 * active theme renders its standard page chrome around the shortcode.
+	 */
+	public function add_rewrite_rule(): void {
+		$page_id = (int) wc_get_page_id( self::PAGE_KEY );
+		if ( $page_id <= 0 ) {
+			return;
+		}
+
+		$page = get_post( $page_id );
+		if ( ! $page instanceof WP_Post || 'publish' !== $page->post_status ) {
+			return;
+		}
+
+		// Use the full page-permalink path so hierarchical pages
+		// (Review Order page moved under a parent) keep working.
+		$permalink = get_permalink( $page_id );
+		if ( ! is_string( $permalink ) || '' === $permalink ) {
+			return;
+		}
+		$path = trim( (string) wp_make_link_relative( $permalink ), '/' );
+		if ( '' === $path ) {
+			return;
+		}
+
+		add_rewrite_rule(
+			'^' . preg_quote( $path, '/' ) . '/([0-9]+)/?$',
+			'index.php?page_id=' . $page_id . '&' . self::QUERY_VAR . '=$matches[1]',
+			'top'
+		);
+	}
+
+	/**
+	 * Allow the query var through `WP::parse_request()`.
+	 *
+	 * @param string[] $vars Query vars.
+	 * @return string[]
+	 */
+	public function add_query_var( array $vars ): array {
+		$vars[] = self::QUERY_VAR;
+		return $vars;
+	}
+
+	/**
+	 * Run the gating checks before the page template renders.
+	 *
+	 * Auth failures fall through to a 404 here rather than inside the
+	 * shortcode so the response status is set before any output begins.
+	 * On success the request continues into normal page rendering and the
+	 * shortcode echoes the body inside `the_content`.
+	 */
+	public function gate_request(): void {
+		global $wp;
+
+		// Only act when the request resolves to the WC-managed Review Order
+		// page. A leftover review-order query var on some other page (manual
+		// URL tampering, third-party plugin) shouldn't trigger our auth
+		// path or 404 an unrelated page.
+		$page_id = (int) wc_get_page_id( self::PAGE_KEY );
+		if ( $page_id <= 0 || ! is_page( $page_id ) ) {
+			return;
+		}
+
+		// Use isset() rather than empty() so the literal "0" doesn't slip
+		// through to normal WP routing; the auth check 404s on order_id 0.
+		if ( ! isset( $wp->query_vars[ self::QUERY_VAR ] ) ) {
+			// Visiting the host page directly (no order id in the URL) is a
+			// dead end — the shortcode renders nothing and the customer
+			// sees a chrome-only page. Send them to the home page instead.
+			wp_safe_redirect( home_url( '/' ) );
+			exit;
+		}
+
+		$order_id  = absint( $wp->query_vars[ self::QUERY_VAR ] );
+		$order_key = $this->read_order_key();
+		$order     = $order_id ? wc_get_order( $order_id ) : false;
+
+		if ( ! $this->is_authorised( $order, $order_key ) ) {
+			$this->render_404();
+			exit;
+		}
+	}
+
+	/**
+	 * Render the Review Order page body for the WC-managed page.
+	 *
+	 * Called by `the_content` on the page that hosts `[woocommerce_review_order]`.
+	 * Returns an empty string when the request did not arrive through the
+	 * tokenised rewrite, so a logged-in admin previewing the page directly
+	 * sees nothing rather than a partial form.
+	 *
+	 * @return string
+	 */
+	public function render_shortcode(): string {
+		global $wp;
+
+		if ( ! isset( $wp->query_vars[ self::QUERY_VAR ] ) ) {
+			return '';
+		}
+
+		$order_id = absint( $wp->query_vars[ self::QUERY_VAR ] );
+		$order    = $order_id ? wc_get_order( $order_id ) : false;
+		if ( ! $order instanceof WC_Order ) {
+			// gate_request() will already have 404'd; this is defensive.
+			return '';
+		}
+
+		ob_start();
+		wc_get_template( 'order/customer-review-order.php', array( 'order' => $order ) );
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * Render the Review Order body directly. Public so unit tests can drive
+	 * the rendering path without staging a global request and the rewrite.
+	 *
+	 * @internal
+	 *
+	 * @param int $order_id Order id parsed from the URL.
+	 */
+	public function render( int $order_id ): void {
+		$order_key = $this->read_order_key();
+		$order     = $order_id ? wc_get_order( $order_id ) : false;
+
+		if ( ! $this->is_authorised( $order, $order_key ) ) {
+			$this->render_404();
+			return;
+		}
+
+		wc_get_template( 'order/customer-review-order.php', array( 'order' => $order ) );
+	}
+
+	/**
+	 * Build the public, tokenised URL for an order's review-order page.
+	 *
+	 * @param WC_Order $order Order to build the URL for.
+	 * @return string
+	 */
+	public static function get_url( WC_Order $order ): string {
+		$page_id   = (int) wc_get_page_id( self::PAGE_KEY );
+		$permalink = (string) ( $page_id > 0 ? get_permalink( $page_id ) : '' );
+
+		if ( '' === $permalink ) {
+			$url = '';
+		} elseif ( false === strpos( $permalink, '?' ) ) {
+			// Pretty permalinks: append the order id as a path segment.
+			$url = trailingslashit( $permalink ) . (string) $order->get_id() . '/';
+			$url = add_query_arg( 'key', $order->get_order_key(), $url );
+		} else {
+			// Plain permalinks: page permalink is /?page_id=NNN, so add the
+			// order id as a query var rather than munging the path.
+			$url = add_query_arg(
+				array(
+					self::QUERY_VAR => (string) $order->get_id(),
+					'key'           => $order->get_order_key(),
+				),
+				$permalink
+			);
+		}
+
+		/**
+		 * Filter the Review Order URL that the review-request email links to.
+		 *
+		 * @since 10.8.0
+		 *
+		 * @param string   $url   The review-order URL.
+		 * @param WC_Order $order The order object.
+		 */
+		return (string) apply_filters( 'woocommerce_review_order_url', $url, $order );
+	}
+
+	/**
+	 * Read the order key from the request, sanitised.
+	 *
+	 * @return string
+	 */
+	private function read_order_key(): string {
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only landing page; the order key is the auth.
+		$raw = ( isset( $_GET['key'] ) && is_string( $_GET['key'] ) ) ? wc_clean( wp_unslash( $_GET['key'] ) ) : '';
+		return is_string( $raw ) ? $raw : '';
+	}
+
+	/**
+	 * Decide whether the request is allowed to render the page.
+	 *
+	 * @param mixed  $order     The candidate order. Anything other than a `WC_Order` fails.
+	 * @param string $order_key The order key supplied via query arg.
+	 * @return bool
+	 */
+	private function is_authorised( $order, string $order_key ): bool {
+		if ( ! $order instanceof WC_Order ) {
+			return false;
+		}
+
+		if ( '' === $order_key || ! hash_equals( $order->get_order_key(), $order_key ) ) {
+			return false;
+		}
+
+		/**
+		 * Filter the order statuses that are eligible to access the Review Order page.
+		 *
+		 * The scheduler unschedules pending sends on refund/cancel/trash/delete, but
+		 * emails already in the customer's inbox can still be clicked. The route-level
+		 * check blocks those late clicks for orders that have moved out of the
+		 * eligible set.
+		 *
+		 * @since 10.8.0
+		 *
+		 * @param string[] $eligible_statuses Status slugs without the `wc-` prefix.
+		 * @param WC_Order $order             The order being reviewed.
+		 */
+		$eligible_statuses = (array) apply_filters(
+			'woocommerce_review_order_eligible_statuses',
+			array( OrderStatus::COMPLETED ),
+			$order
+		);
+
+		if ( ! in_array( $order->get_status(), $eligible_statuses, true ) ) {
+			return false;
+		}
+
+		// Logged-in customer must own the order. Guests with the order key still pass.
+		if ( $order->get_customer_id() && is_user_logged_in() && get_current_user_id() !== $order->get_customer_id() ) {
+			return false;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Mark the current request as a 404 and load the theme's 404 template.
+	 *
+	 * Fails closed on every gating check so a stale or tampered link cannot
+	 * disclose order existence.
+	 */
+	private function render_404(): void {
+		global $wp_query;
+
+		$wp_query->set_404();
+		status_header( 404 );
+		nocache_headers();
+
+		$template = get_query_template( '404' );
+		if ( ! empty( $template ) && file_exists( $template ) ) {
+			include $template;
+			return;
+		}
+
+		// Fallback when the active theme has no 404 template: emit a minimal
+		// page so the response body isn't empty.
+		printf(
+			'<!doctype html><html><head><meta charset="utf-8"><title>%1$s</title></head><body><h1>%1$s</h1></body></html>',
+			esc_html__( 'Page not found', 'woocommerce' )
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php b/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php
index 0452c675508..f2a5302d942 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php
@@ -7,7 +7,6 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Internal\OrderReviews;

-use Automattic\WooCommerce\Internal\RegisterHooksInterface;
 use WC_Email_Customer_Review_Request;
 use WC_Order;

@@ -19,11 +18,15 @@ use WC_Order;
  * configured in the email's settings. Cancels the pending action when the
  * order is later refunded, cancelled, trashed or deleted.
  *
+ * The container auto-calls `init()` after instantiation, which is where
+ * the WordPress hooks are registered. Resolution is driven by the
+ * `OrderReviews` wrapper that lists this class as an `init()` argument.
+ *
  * @internal Just for internal use.
  *
  * @since 10.8.0
  */
-class Scheduler implements RegisterHooksInterface {
+class Scheduler {

 	/**
 	 * Action Scheduler hook fired when the configured delay elapses. The
@@ -45,8 +48,12 @@ class Scheduler implements RegisterHooksInterface {

 	/**
 	 * Register hooks and filters.
+	 *
+	 * Auto-called by the WC dependency container after instantiation.
+	 *
+	 * @internal
 	 */
-	public function register(): void {
+	final public function init(): void {
 		add_action( 'woocommerce_order_status_completed', array( $this, 'handle_woocommerce_order_status_completed' ), 10, 1 );
 		add_action( 'woocommerce_order_status_cancelled', array( $this, 'handle_cancellation' ), 10, 1 );
 		add_action( 'woocommerce_order_status_refunded', array( $this, 'handle_cancellation' ), 10, 1 );
diff --git a/plugins/woocommerce/templates/order/customer-review-order.php b/plugins/woocommerce/templates/order/customer-review-order.php
new file mode 100644
index 00000000000..aecd676209b
--- /dev/null
+++ b/plugins/woocommerce/templates/order/customer-review-order.php
@@ -0,0 +1,122 @@
+<?php
+/**
+ * Customer Review Order page
+ *
+ * Read-only landing page surfaced from the Customer Review Request email.
+ * Lists the eligible line items from a completed order so the customer can
+ * review what they purchased.
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/order/customer-review-order.php.
+ *
+ * HOWEVER, on occasion WooCommerce will need to update template files and you
+ * (the theme developer) will need to copy the new files to your theme to
+ * maintain compatibility. We try to do this as little as possible, but it does
+ * happen. When this occurs the version of the template file will be bumped and
+ * the readme will list any important changes.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates
+ * @version 10.8.0
+ *
+ * @var WC_Order $order Order being reviewed.
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+if ( ! $order instanceof WC_Order ) {
+	return;
+}
+
+$date_created    = $order->get_date_created();
+$customer_name   = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
+$customer_email  = $order->get_billing_email();
+$order_number    = $order->get_order_number();
+$order_date_text = $date_created ? wc_format_datetime( $date_created ) : '';
+
+if ( '' !== $order_date_text ) {
+	$order_summary = sprintf(
+		/* translators: 1: order number, 2: order date */
+		__( 'Order #%1$s (%2$s)', 'woocommerce' ),
+		$order_number,
+		$order_date_text
+	);
+} else {
+	$order_summary = sprintf(
+		/* translators: %s: order number */
+		__( 'Order #%s', 'woocommerce' ),
+		$order_number
+	);
+}
+
+$meta_parts = array_filter(
+	array(
+		$customer_name,
+		$customer_email,
+		$order_summary,
+	)
+);
+
+/**
+ * Filter the eligible items rendered on the Review Order page.
+ *
+ * Defaults to the order's line items. Extensions can use this to hide items
+ * that have already been reviewed or are otherwise ineligible.
+ *
+ * @since 10.8.0
+ *
+ * @param WC_Order_Item[] $items Order line items.
+ * @param WC_Order        $order The order being reviewed.
+ */
+$items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
+?>
+<div class="woocommerce-review-order">
+	<p class="woocommerce-review-order__meta">
+		<?php echo esc_html( implode( ' · ', $meta_parts ) ); ?>
+	</p>
+
+	<h1 class="woocommerce-review-order__title">
+		<?php esc_html_e( 'Review your order', 'woocommerce' ); ?>
+	</h1>
+
+	<p class="woocommerce-review-order__intro">
+		<?php esc_html_e( 'Loved something? Not so much? Share a quick review for what you bought. Feel free to skip any product.', 'woocommerce' ); ?>
+	</p>
+
+	<p class="woocommerce-review-order__legend">
+		<?php esc_html_e( '* Mandatory fields', 'woocommerce' ); ?>
+	</p>
+
+	<?php if ( ! empty( $items ) ) : ?>
+		<ul class="woocommerce-review-order__items">
+			<?php foreach ( $items as $item ) : ?>
+				<?php
+				if ( ! $item instanceof WC_Order_Item_Product ) {
+					continue;
+				}
+				$product = $item->get_product();
+				if ( ! $product instanceof WC_Product ) {
+					continue;
+				}
+				$product_link = $product->is_visible() ? get_permalink( $product->get_id() ) : '';
+				$product_name = $item->get_name();
+				$image_html   = $product->get_image( 'woocommerce_thumbnail' );
+				?>
+				<li class="woocommerce-review-order__item">
+					<p class="woocommerce-review-order__item-title">
+						<?php if ( $product_link ) : ?>
+							<a href="<?php echo esc_url( $product_link ); ?>"><?php echo esc_html( $product_name ); ?></a>
+						<?php else : ?>
+							<?php echo esc_html( $product_name ); ?>
+						<?php endif; ?>
+					</p>
+					<div class="woocommerce-review-order__item-row">
+						<div class="woocommerce-review-order__item-image">
+							<?php echo $image_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_image() returns escaped HTML. ?>
+						</div>
+						<div class="woocommerce-review-order__item-form-placeholder"></div>
+					</div>
+				</li>
+			<?php endforeach; ?>
+		</ul>
+	<?php endif; ?>
+</div>
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
new file mode 100644
index 00000000000..3f2b6bf7d5a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
@@ -0,0 +1,238 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\OrderReviews;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\OrderReviews\Endpoint;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use WC_Unit_Test_Case;
+use WP_Query;
+
+/**
+ * Tests for the standalone Review Order endpoint and `wc_get_review_order_url()` helper.
+ */
+class EndpointTest extends WC_Unit_Test_Case {
+
+	/**
+	 * System under test.
+	 *
+	 * @var Endpoint
+	 */
+	private Endpoint $endpoint;
+
+	/**
+	 * Set up a fresh endpoint instance and a clean query.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->endpoint = new Endpoint();
+
+		// `Endpoint::get_url()` derives the URL from the WC-managed Review
+		// Order page; tests need that page to exist to exercise the helper.
+		// Test transactions roll the post back between runs but the option
+		// persists, so the existing id may point at a deleted post — recreate
+		// when that's the case.
+		$existing = (int) wc_get_page_id( Endpoint::PAGE_KEY );
+		if ( $existing <= 0 || ! get_post( $existing ) instanceof \WP_Post ) {
+			$page_id = (int) wp_insert_post(
+				array(
+					'post_type'    => 'page',
+					'post_status'  => 'publish',
+					'post_title'   => 'Review your order',
+					'post_name'    => 'review-order',
+					'post_content' => '[woocommerce_review_order]',
+				)
+			);
+			update_option( 'woocommerce_review_order_page_id', $page_id );
+		}
+	}
+
+	/**
+	 * Reset $_GET, the global query, and any logged-in user between tests.
+	 */
+	public function tearDown(): void {
+		$_GET = array();
+		global $wp_query;
+		if ( $wp_query instanceof WP_Query ) {
+			$wp_query->is_404 = false;
+		}
+		wp_set_current_user( 0 );
+		parent::tearDown();
+	}
+
+	/**
+	 * Run the gating + render with output captured.
+	 *
+	 * @param int $order_id Order id to dispatch.
+	 * @return string Rendered HTML.
+	 */
+	private function render( int $order_id ): string {
+		ob_start();
+		$this->endpoint->render( $order_id );
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * @testdox The query var is registered with WP.
+	 */
+	public function test_query_var_filter_adds_review_order(): void {
+		$vars = $this->endpoint->add_query_var( array( 'foo' ) );
+		$this->assertContains( Endpoint::QUERY_VAR, $vars );
+	}
+
+	/**
+	 * @testdox wc_get_review_order_url returns a tokenized URL pointing at the new endpoint.
+	 */
+	public function test_helper_returns_tokenized_url(): void {
+		$order = OrderHelper::create_order();
+		$url   = wc_get_review_order_url( $order );
+
+		// Path style on pretty permalinks, query-arg style on plain — accept either.
+		$this->assertMatchesRegularExpression(
+			'#review-order[/=]' . $order->get_id() . '#',
+			$url
+		);
+		$this->assertStringContainsString( 'key=' . $order->get_order_key(), $url );
+	}
+
+	/**
+	 * @testdox wc_get_review_order_url returns empty string for non-order input.
+	 */
+	public function test_helper_empty_for_non_order(): void {
+		$this->assertSame( '', wc_get_review_order_url( null ) );
+		$this->assertSame( '', wc_get_review_order_url( 0 ) );
+		$this->assertSame( '', wc_get_review_order_url( new \stdClass() ) );
+	}
+
+	/**
+	 * @testdox The woocommerce_review_order_url filter can replace the helper output.
+	 */
+	public function test_helper_filterable(): void {
+		$order    = OrderHelper::create_order();
+		$override = static function () {
+			return 'https://example.test/custom';
+		};
+		add_filter( 'woocommerce_review_order_url', $override );
+
+		$this->assertSame( 'https://example.test/custom', wc_get_review_order_url( $order ) );
+
+		remove_filter( 'woocommerce_review_order_url', $override );
+	}
+
+	/**
+	 * @testdox 404s when the order id does not resolve.
+	 */
+	public function test_404_when_order_missing(): void {
+		$this->render( 999999 );
+
+		global $wp_query;
+		$this->assertTrue( $wp_query->is_404 );
+	}
+
+	/**
+	 * @testdox 404s when no key query arg is supplied.
+	 */
+	public function test_404_when_key_missing(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$_GET = array();
+
+		$this->render( $order->get_id() );
+
+		global $wp_query;
+		$this->assertTrue( $wp_query->is_404 );
+	}
+
+	/**
+	 * @testdox 404s when the supplied key does not match the order key.
+	 */
+	public function test_404_when_key_mismatched(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$_GET = array( 'key' => 'wc_order_definitelywrong' );
+
+		$this->render( $order->get_id() );
+
+		global $wp_query;
+		$this->assertTrue( $wp_query->is_404 );
+	}
+
+	/**
+	 * @testdox 404s when the order status is not in the eligible set.
+	 */
+	public function test_404_when_status_ineligible(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+		$_GET = array( 'key' => $order->get_order_key() );
+
+		$this->render( $order->get_id() );
+
+		global $wp_query;
+		$this->assertTrue( $wp_query->is_404 );
+	}
+
+	/**
+	 * @testdox 404s when a logged-in user does not own the order.
+	 */
+	public function test_404_when_logged_in_customer_mismatch(): void {
+		$customer_id = self::factory()->user->create();
+		$other_id    = self::factory()->user->create();
+
+		$order = OrderHelper::create_order( $customer_id );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		wp_set_current_user( $other_id );
+		$_GET = array( 'key' => $order->get_order_key() );
+
+		$this->render( $order->get_id() );
+
+		global $wp_query;
+		$this->assertTrue( $wp_query->is_404 );
+	}
+
+	/**
+	 * @testdox Renders the template for a valid completed-order link.
+	 */
+	public function test_renders_template_on_success(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$_GET = array( 'key' => $order->get_order_key() );
+
+		$html = $this->render( $order->get_id() );
+
+		global $wp_query;
+		$this->assertFalse( $wp_query->is_404 );
+		$this->assertStringContainsString( 'woocommerce-review-order', $html );
+		$this->assertStringContainsString( 'Review your order', $html );
+		$this->assertStringContainsString( 'Order #' . $order->get_order_number(), $html );
+	}
+
+	/**
+	 * @testdox The woocommerce_review_order_eligible_statuses filter widens the eligible set.
+	 */
+	public function test_eligible_statuses_filter_widens_set(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+		$_GET = array( 'key' => $order->get_order_key() );
+
+		$widen = static function () {
+			return array( OrderStatus::COMPLETED, OrderStatus::PROCESSING );
+		};
+		add_filter( 'woocommerce_review_order_eligible_statuses', $widen );
+
+		$html = $this->render( $order->get_id() );
+
+		remove_filter( 'woocommerce_review_order_eligible_statuses', $widen );
+
+		global $wp_query;
+		$this->assertFalse( $wp_query->is_404 );
+		$this->assertStringContainsString( 'woocommerce-review-order', $html );
+	}
+}