Commit bb55e459270 for woocommerce

commit bb55e459270a1254c9123d4feee02b94997276f9
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date:   Mon Sep 7 20:29:04 2026 +0100

    Add My Account tab for Back in Stock Notifications (RSM-444) (#64439)

    * Add My Account endpoint for back in stock notifications

    Registers the back-in-stock-notifications endpoint under WC > My Account
    with a matching template that renders the current user's signups as an
    accessible table. Wired via woocommerce_get_query_vars (rewrite endpoint),
    woocommerce_account_menu_items (menu label), and the standard
    woocommerce_account_<slug>_endpoint action. Notifications are fetched
    exclusively via NotificationQuery::get_notifications() scoped to
    get_current_user_id(), never by client-supplied ids.

    The whole MyAccountEndpoint is instantiated from StockNotifications
    init_hooks(), which is already gated behind WOOCOMMERCE_BIS_ALPHA_ENABLED
    in class-woocommerce.php, so the endpoint and menu item stay hidden
    whenever the alpha constant is off.

    * Add PHPUnit coverage for My Account BIS endpoint

    Covers the customer-facing scenarios that were previously only exercised
    in ad-hoc manual testing:

    - Endpoint helper returns only the current user's notifications.
    - User A cannot see user B's notifications.
    - Anonymous visitors get an empty list (no query by client ids).
    - Empty state for users with no signups.
    - Cancel with a valid per-notification nonce flips to cancelled +
      records cancellation source = user.
    - Cancel with an invalid nonce is a no-op.
    - Cross-notification nonce replay (A's nonce + B's id) is a no-op.
    - Ownership check blocks user A cancelling user B's notification.
    - Anonymous cancel POST is silently dropped.
    - Menu filter registers the label and preserves Log out at the end.
    - Query var filter adds the expected slug.

    * Add Playwright coverage for My Account BIS tab

    Covers the customer-facing surface in tests/e2e-pw:

    - Logged-in customer with one pending + one active notification sees
      both in the tab with the expected Status cells.
    - Clicking Cancel on a pending row flips it to Cancelled and disables
      the button.
    - Empty state renders the friendly copy and a Browse products link.
    - Anonymous visitor hitting /my-account/back-in-stock-notifications/
      gets the standard WC login form.

    The helper file utils/back-in-stock-notifications.ts matches the
    version on rsm-437-e2e-followup so the two branches merge cleanly
    without duplicating or forking the helper surface.

    * Drop the Status column from the My Account BIS notifications table

    The status (active / pending / sent / cancelled) is implicit from the
    Cancel button state — the button is disabled for sent/cancelled rows
    and active for everything else — so the dedicated column was just
    visual noise. Drop the column header, cell, and status-label map.

    The underlying `$status` is still computed because the Cancel-button
    gating and the row's `--status-<value>` CSS hook both depend on it.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Shorten the My Account menu label to "Stock notifications"

    "Back in stock notifications" was wider than every other My Account
    sidebar item ("Orders", "Downloads", "Addresses", "Account details",
    "Log out") and pushed the column off-grid. The endpoint slug stays
    `back-in-stock-notifications` for URL stability; only the label and
    the endpoint page title shorten.

    Updates the matching PHPUnit + Playwright assertions.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Paginate the My Account stock notifications table

    Default `per_page` is 10. The current page comes from WC's rewrite
    endpoint capture — `/my-account/back-in-stock-notifications/2/` lands
    in the `woocommerce_account_back-in-stock-notifications_endpoint` hook
    as the first arg, mirroring how `/my-account/orders/2/` works.

    Implementation notes:

    - `NotificationQuery` gains a `count_notifications()` wrapper so the
      endpoint can ask for a total without going through `get_notifications`,
      whose `array` return type can't honour the data store's `int` count
      return. Both wrappers funnel through a single private `run_query`
      helper so the existing `WC_Data_Store::query()` PHPStan suppression
      in `phpstan-baseline.neon` keeps applying to one call site, not two.
    - `MyAccountEndpoint::get_current_user_notifications_page()` returns a
      struct with `notifications`, `current_page`, `total_pages`,
      `total_items`. Out-of-range page numbers clamp to the last page so a
      stale link doesn't render an empty table.
    - Per-page count is filterable via `woocommerce_account_back_in_stock_notifications_per_page`.
    - Template renders WP core's `paginate_links()` below the table when
      more than one page exists, matching the orders endpoint's pattern.

    Drive-by PHPStan cleanups:

    - Drop a redundant `is_array()` check on a value already guaranteed to
      be an array by `NotificationQuery::get_notifications()`'s return type.
    - Narrow `Factory::get_notification()` returns with
      `instanceof Notification` in the cancel handler, since the helper
      returns `Notification|true` and the previous truthy-only check
      couldn't filter `true` out.

    Tests:

    - Existing endpoint-helper tests migrated to the new
      `get_current_user_notifications_page()` signature.
    - New tests cover pagination math (page 2 of 7 with per_page=3 returns
      rows 4-6 in `id` DESC order) and the out-of-range clamp.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Hide SENT and CANCELLED notifications from the My Account view

    The My Account view is "what am I waiting for" — once the email has
    been dispatched (SENT) or the customer has cancelled, the row is just
    noise. Filter the query to PENDING + ACTIVE only and let the merchant
    see the full history in the BIS admin (which already shows everything).

    Knock-on simplifications:

    - Drop the `disabled` Cancel button branch from the template — every
      visible row is actionable now, so the button is always live.
    - Drop the `$is_cancelled` / `$is_sent` template variables and the
      `NotificationStatus` import from the template, both unused.
    - Cancelling a row makes it vanish from the table; the toast notice
      ("Back in stock notification for X cancelled.") is the only feedback.
      Cleaner than leaving a tombstone row with a disabled button.

    Plumbing:

    - `StockNotificationsDataStore::query()` now accepts an array for
      `status` (mirroring how `product_id` already accepts arrays) and
      emits `status IN (...)`. Backwards-compatible — single-string status
      args still work via the `(array)` cast.
    - Default statuses are filterable via
      `woocommerce_account_back_in_stock_notifications_statuses` for
      merchants who want a different view (e.g. include SENT for a
      history-style listing).

    Tests:

    - New PHPUnit case asserts SENT + CANCELLED are excluded.
    - Playwright cancel test rewritten — used to assert "Cancelled" cell +
      disabled button; now asserts the row is gone + a notice is shown.
    - Two-rows test stops asserting Status cells (Status column was
      dropped earlier; the assertion was leftover).

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Rename the My Account endpoint slug to `stock-notifications`

    URL goes from `/my-account/back-in-stock-notifications/` to
    `/my-account/stock-notifications/` to match the menu label "Stock
    notifications". Renames:

    - `MyAccountEndpoint::ENDPOINT` constant.
    - The endpoint template, `myaccount/back-in-stock-notifications.php`
      → `myaccount/stock-notifications.php` (matching the WC convention
      that template basename equals the endpoint slug — see
      `myaccount/orders.php`, `myaccount/downloads.php`).
    - Pagination `aria-label` to "Stock notifications pagination".
    - Slug references in docblocks and the e2e spec URLs.

    Knock-on auto-changes:

    - The endpoint dispatch hook becomes
      `woocommerce_account_stock-notifications_endpoint` (driven by the
      new constant).
    - The query var driving `is_stock_notifications_endpoint` checks is
      now `$wp->query_vars['stock-notifications']`.

    Deliberately NOT renamed (these name the FEATURE, not the URL):

    - `woocommerce_account_back_in_stock_notifications_per_page` filter.
    - `woocommerce_account_back_in_stock_notifications_statuses` filter.
    - `woocommerce_before_account_back_in_stock_notifications` /
      `woocommerce_after_account_back_in_stock_notifications` actions.
    - `.woocommerce-back-in-stock-notifications-*` CSS classes.
    - The e2e test directory + utils module path.

    Production sites will need a one-time `wp rewrite flush` (or any
    permalink-settings save) after upgrading for the new URL to resolve.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * style: fix phpcs and eslint findings in My Account BIS files

    * refactor: rename BIS my-account classes and hooks to customer-stock-notifications

    * fix: wrap stock notifications table headers in nobr spans

    * fix: make stock notifications Product cell a row header for screen readers

    * fix: drop redundant Actions label from stock notifications table on mobile

    * fix: show stock notification variations under the My Account product name

    * add: changelog entry for the My Account stock notifications tab

    * fix: exit after the stock notification cancel redirect so the notice shows

    * test: fix stale My Account stock notifications Playwright spec

    * style: drop unescaped exception message from stock notifications tests

    * fix: only offer Cancel on cancellable stock notifications

    * fix: name the product in each stock notification Cancel button

    * fix: report an error when a stock notification cancel fails

    * docs: drop orders-pagination reference from stock notifications hook

    * perf: prime the post cache for My Account stock notification products

    * fix: report an error when a stock notification cancel doesn't persist

    * test: use exact string locators in the My Account stock notifications spec

    * refactor: fold stock notifications table CSS into existing responsive rules

    * fix: keep stock notifications Product column visible without custom CSS

    * test: expect the stock notifications endpoint in the core account tests

    * perf: hydrate stock notification objects from the rows already queried

    * fix: keep the customer on their page after cancelling a stock notification

    * revert: restore the per-row read on stock notification object queries

    ---------

    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    Co-authored-by: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/64439-fix-stock-notifications-cancel-page b/plugins/woocommerce/changelog/64439-fix-stock-notifications-cancel-page
new file mode 100644
index 00000000000..5f04ac4dc6f
--- /dev/null
+++ b/plugins/woocommerce/changelog/64439-fix-stock-notifications-cancel-page
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stock notifications: return the customer to the page they cancelled a notification from, instead of the first page of the list.
diff --git a/plugins/woocommerce/changelog/64439-my-account-stock-notifications b/plugins/woocommerce/changelog/64439-my-account-stock-notifications
new file mode 100644
index 00000000000..eacb32b7cf5
--- /dev/null
+++ b/plugins/woocommerce/changelog/64439-my-account-stock-notifications
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a Stock notifications tab to My Account, so customers can review the back-in-stock sign-ups they have made and cancel the ones they no longer want.
diff --git a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
index 3d94d603c85..22ba7c06345 100644
--- a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
@@ -440,9 +440,12 @@ CREATE TABLE $meta_table_name (
 		$where        = array();
 		$where_values = array();

-		if ( $args['status'] ) {
-			$where[]        = 'status = %s';
-			$where_values[] = esc_sql( $args['status'] );
+		if ( ! empty( $args['status'] ) ) {
+			$statuses = array_values( array_filter( array_map( 'strval', (array) $args['status'] ) ) );
+			if ( ! empty( $statuses ) ) {
+				$where[]      = 'status IN (' . implode( ',', array_fill( 0, count( $statuses ), '%s' ) ) . ')';
+				$where_values = array_merge( $where_values, $statuses );
+			}
 		}

 		if ( ! empty( $args['product_id'] ) ) {
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/MyAccountEndpoint.php b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/MyAccountEndpoint.php
new file mode 100644
index 00000000000..aaebc1dffed
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/MyAccountEndpoint.php
@@ -0,0 +1,399 @@
+<?php
+/**
+ * MyAccountEndpoint class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\StockNotifications\Frontend;
+
+use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource;
+use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
+use Automattic\WooCommerce\Internal\StockNotifications\Factory;
+use Automattic\WooCommerce\Internal\StockNotifications\Notification;
+use Automattic\WooCommerce\Internal\StockNotifications\NotificationQuery;
+
+/**
+ * Registers the "Stock notifications" My Account endpoint and handles
+ * the cancel action for customer-owned notifications.
+ *
+ * @internal
+ */
+class MyAccountEndpoint {
+
+	/**
+	 * Query var / endpoint slug.
+	 *
+	 * Matches the slug used in the menu filter, rewrite endpoint, and template hook.
+	 */
+	public const ENDPOINT = 'stock-notifications';
+
+	/**
+	 * Query argument triggered by the cancel form post.
+	 */
+	public const CANCEL_ACTION = 'wc_bis_cancel_notification';
+
+	/**
+	 * Build the nonce action name for a given notification id.
+	 *
+	 * Same shape as the admin-side scoping from #64348: `wc_bis_cancel_<id>`.
+	 *
+	 * @param int $notification_id The notification id.
+	 * @return string The nonce action name.
+	 */
+	public static function get_cancel_nonce_action( int $notification_id ): string {
+		return 'wc_bis_cancel_' . $notification_id;
+	}
+
+	/**
+	 * Statuses a customer can still cancel from My Account.
+	 *
+	 * The same list backs the listing query, the Cancel button in the template
+	 * and the cancel request handler, so the three can't drift apart.
+	 *
+	 * @return string[] List of {@see NotificationStatus} values.
+	 */
+	public static function get_cancellable_statuses(): array {
+		return array( NotificationStatus::PENDING, NotificationStatus::ACTIVE );
+	}
+
+	/**
+	 * Check whether a notification can still be cancelled by the customer.
+	 *
+	 * @param Notification $notification The notification to check.
+	 * @return bool True when the notification is in a cancellable status.
+	 */
+	public static function is_cancellable( Notification $notification ): bool {
+		return in_array( (string) $notification->get_status(), self::get_cancellable_statuses(), true );
+	}
+
+	/**
+	 * Constructor.
+	 */
+	public function __construct() {
+		add_filter( 'woocommerce_get_query_vars', array( $this, 'register_query_var' ) );
+		add_filter( 'woocommerce_account_menu_items', array( $this, 'register_menu_item' ), 10, 2 );
+		add_filter( 'woocommerce_endpoint_' . self::ENDPOINT . '_title', array( $this, 'filter_endpoint_title' ) );
+		add_action( 'woocommerce_account_' . self::ENDPOINT . '_endpoint', array( $this, 'render_endpoint' ) );
+		add_action( 'template_redirect', array( $this, 'maybe_handle_cancel' ) );
+	}
+
+	/**
+	 * Register the `stock-notifications` rewrite endpoint / query var.
+	 *
+	 * Hooking `woocommerce_get_query_vars` wires us into {@see \WC_Query::add_endpoints()}
+	 * so WordPress registers the rewrite rule and our slug lands in `$wp->query_vars`.
+	 *
+	 * @param array<string, string> $vars Existing query vars keyed by endpoint slug.
+	 * @return array<string, string>
+	 */
+	public function register_query_var( $vars ) {
+		if ( ! is_array( $vars ) ) {
+			return $vars;
+		}
+
+		$vars[ self::ENDPOINT ] = self::ENDPOINT;
+		return $vars;
+	}
+
+	/**
+	 * Inject the menu entry between "Downloads" and "Addresses" if present, otherwise append.
+	 *
+	 * @param array<string, string> $items     The current menu items.
+	 * @param array<string, string> $endpoints The resolved endpoint slugs (unused, kept for filter signature).
+	 * @return array<string, string>
+	 */
+	public function register_menu_item( $items, $endpoints ) {
+		// Avoid parameter not used PHPCS errors.
+		unset( $endpoints );
+
+		if ( ! is_array( $items ) ) {
+			return $items;
+		}
+
+		$new_item = array(
+			self::ENDPOINT => __( 'Stock notifications', 'woocommerce' ),
+		);
+
+		// Try to slot it in right after Downloads so it sits with the other lists.
+		if ( isset( $items['downloads'] ) ) {
+			$position = array_search( 'downloads', array_keys( $items ), true );
+			if ( false !== $position ) {
+				return array_slice( $items, 0, $position + 1, true )
+					+ $new_item
+					+ array_slice( $items, $position + 1, null, true );
+			}
+		}
+
+		// Otherwise insert before customer-logout so "Log out" stays last.
+		if ( isset( $items['customer-logout'] ) ) {
+			$position = array_search( 'customer-logout', array_keys( $items ), true );
+			if ( false !== $position ) {
+				return array_slice( $items, 0, $position, true )
+					+ $new_item
+					+ array_slice( $items, $position, null, true );
+			}
+		}
+
+		return $items + $new_item;
+	}
+
+	/**
+	 * Override the endpoint page title.
+	 *
+	 * @param string $title The default title.
+	 * @return string
+	 */
+	public function filter_endpoint_title( $title ) {
+		// Avoid parameter not used PHPCS errors.
+		unset( $title );
+		return __( 'Stock notifications', 'woocommerce' );
+	}
+
+	/**
+	 * Default page size when none is provided.
+	 *
+	 * @var int
+	 */
+	public const DEFAULT_PER_PAGE = 10;
+
+	/**
+	 * Product name to show as the row label.
+	 *
+	 * {@see Notification::get_product_name()} returns the variation name, which
+	 * already carries the attributes ("Hoodie - Blue, Large"). The row renders
+	 * those attributes separately underneath, so use the parent title here and
+	 * let the variation list own them.
+	 *
+	 * @param Notification $notification The notification to label.
+	 * @return string Product name, or an empty string when the product is gone.
+	 */
+	public static function get_display_product_name( Notification $notification ): string {
+		$product = $notification->get_product();
+		if ( ! $product ) {
+			return '';
+		}
+
+		return (string) $product->get_title();
+	}
+
+	/**
+	 * Render the endpoint template.
+	 *
+	 * Hooked to `woocommerce_account_stock-notifications_endpoint`, mirroring
+	 * how `woocommerce_account_downloads` and `woocommerce_account_orders` hook up.
+	 *
+	 * @param string|int $current_page The current page number passed by WC (the value
+	 *                                 captured from the rewrite endpoint, e.g. `2` for
+	 *                                 `/my-account/stock-notifications/2/`). Empty
+	 *                                 string when no page is in the URL.
+	 */
+	public function render_endpoint( $current_page = 1 ): void {
+		$current_page = max( 1, (int) $current_page );
+
+		/**
+		 * Filter the per-page count for the My Account stock-notifications table.
+		 *
+		 * @since 11.2.0
+		 *
+		 * @param int $per_page Number of notifications shown per page. Default {@see self::DEFAULT_PER_PAGE}.
+		 */
+		$per_page = (int) apply_filters( 'woocommerce_account_customer_stock_notifications_per_page', self::DEFAULT_PER_PAGE );
+		$per_page = max( 1, $per_page );
+
+		$page = $this->get_current_user_notifications_page( $current_page, $per_page );
+
+		\wc_get_template(
+			'myaccount/stock-notifications.php',
+			array(
+				'notifications' => $page['notifications'],
+				'has_items'     => ! empty( $page['notifications'] ),
+				'current_page'  => $page['current_page'],
+				'total_pages'   => $page['total_pages'],
+				'total_items'   => $page['total_items'],
+				'per_page'      => $per_page,
+			)
+		);
+	}
+
+	/**
+	 * Return one page of the current user's notifications, newest first.
+	 *
+	 * Always scopes to `get_current_user_id()` — the caller is never trusted.
+	 *
+	 * @param int $current_page 1-indexed page number.
+	 * @param int $per_page     Page size.
+	 * @return array{notifications:array<Notification>, current_page:int, total_pages:int, total_items:int}
+	 */
+	public function get_current_user_notifications_page( int $current_page, int $per_page ): array {
+		$current_page = max( 1, $current_page );
+		$per_page     = max( 1, $per_page );
+
+		$user_id = get_current_user_id();
+		if ( $user_id <= 0 ) {
+			return array(
+				'notifications' => array(),
+				'current_page'  => 1,
+				'total_pages'   => 0,
+				'total_items'   => 0,
+			);
+		}
+
+		// Only the statuses the customer can act on. SENT and CANCELLED rows are
+		// noise here; merchants who want a full history can build their own view
+		// via {@see NotificationQuery::get_notifications()}.
+		$statuses = self::get_cancellable_statuses();
+
+		$total_items = NotificationQuery::count_notifications(
+			array(
+				'user_id' => $user_id,
+				'status'  => $statuses,
+			)
+		);
+
+		$total_pages = (int) ceil( $total_items / $per_page );
+
+		// Clamp out-of-range pages to the last available page so a stale link
+		// doesn't render an empty table.
+		if ( $total_items > 0 && $current_page > $total_pages ) {
+			$current_page = $total_pages;
+		}
+
+		$notifications = $total_items > 0 ? NotificationQuery::get_notifications(
+			array(
+				'user_id'  => $user_id,
+				'status'   => $statuses,
+				'order_by' => array( 'id' => 'DESC' ),
+				'return'   => 'objects',
+				'limit'    => $per_page,
+				'offset'   => ( $current_page - 1 ) * $per_page,
+			)
+		) : array();
+
+		// The notifications come from a raw SQL select, so nothing has primed the
+		// post cache for the products the rows render. Prime it once here instead
+		// of letting each row's wc_get_product() call issue its own query.
+		if ( $notifications ) {
+			_prime_post_caches( array_filter( array_map( static fn( $notification ) => (int) $notification->get_product_id(), $notifications ) ) );
+		}
+
+		return array(
+			'notifications' => $notifications,
+			'current_page'  => $current_page,
+			'total_pages'   => $total_pages,
+			'total_items'   => $total_items,
+		);
+	}
+
+	/**
+	 * Intercept a cancel POST to flip a notification to `cancelled`.
+	 *
+	 * Guards:
+	 * - Must be on the My Account > stock-notifications endpoint.
+	 * - Must be authenticated.
+	 * - Nonce must be scoped to the specific notification id ({@see ::get_cancel_nonce_action()}).
+	 * - The notification must belong to the current user.
+	 *
+	 * Requests that aren't a cancel submission, and ones whose notification isn't the
+	 * current user's, are dropped silently. A cancel the customer can act on — an expired
+	 * nonce, a notification that's gone or already cancelled, a save that fails — redirects
+	 * back with an error notice instead, so the button never looks dead.
+	 */
+	public function maybe_handle_cancel(): void {
+		global $wp;
+
+		// phpcs:disable WordPress.Security.NonceVerification.Missing
+		if ( ! isset( $_POST[ self::CANCEL_ACTION ] ) ) {
+			return;
+		}
+
+		if ( ! isset( $wp->query_vars[ self::ENDPOINT ] ) ) {
+			return;
+		}
+
+		if ( ! is_user_logged_in() ) {
+			return;
+		}
+
+		$notification_id = isset( $_POST['notification_id'] ) ? absint( wp_unslash( $_POST['notification_id'] ) ) : 0;
+		if ( $notification_id <= 0 ) {
+			return;
+		}
+
+		$nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
+
+		// The page the customer cancelled from, so they land back on it instead of page 1.
+		$page = isset( $_POST['notifications_page'] ) ? max( 1, absint( wp_unslash( $_POST['notifications_page'] ) ) ) : 1;
+		// phpcs:enable WordPress.Security.NonceVerification.Missing
+
+		if ( ! wp_verify_nonce( $nonce, self::get_cancel_nonce_action( $notification_id ) ) ) {
+			$this->redirect_with_error( __( 'This link has expired. Please reload the page and try again.', 'woocommerce' ), $page );
+		}
+
+		$notification = Factory::get_notification( $notification_id );
+		if ( ! $notification instanceof Notification ) {
+			$this->redirect_with_error( __( 'That back in stock notification no longer exists.', 'woocommerce' ), $page );
+		}
+
+		if ( (int) $notification->get_user_id() !== get_current_user_id() ) {
+			return;
+		}
+
+		if ( ! self::is_cancellable( $notification ) ) {
+			$this->redirect_with_error( __( 'That back in stock notification has already been cancelled.', 'woocommerce' ), $page );
+		}
+
+		$notification->set_status( NotificationStatus::CANCELLED );
+		$notification->set_cancellation_source( NotificationCancellationSource::USER );
+		$notification->set_date_cancelled( time() );
+
+		$notification->save();
+
+		// `WC_Data_Store::update()` drops the data store's return value, so a failed write
+		// reaches us as a successful save. Read the row back to confirm it really changed.
+		$saved = Factory::get_notification( $notification->get_id() );
+		if ( ! $saved instanceof Notification || NotificationStatus::CANCELLED !== $saved->get_status() ) {
+			$this->redirect_with_error( __( 'We could not cancel that back in stock notification. Please try again.', 'woocommerce' ), $page );
+		}
+
+		$product_name = $notification->get_product_name();
+		if ( '' !== $product_name ) {
+			\wc_add_notice(
+				sprintf(
+					/* translators: %s: product name */
+					esc_html__( 'Back in stock notification for "%s" cancelled.', 'woocommerce' ),
+					esc_html( $product_name )
+				)
+			);
+		} else {
+			\wc_add_notice( esc_html__( 'Back in stock notification cancelled.', 'woocommerce' ) );
+		}
+
+		wp_safe_redirect( self::get_endpoint_url( $page ) );
+		exit;
+	}
+
+	/**
+	 * Queue an error notice and redirect back to the stock notifications endpoint.
+	 *
+	 * @param string $message The error to show the customer.
+	 * @param int    $page    1-indexed page to return the customer to.
+	 * @return never
+	 */
+	private function redirect_with_error( string $message, int $page = 1 ) {
+		\wc_add_notice( esc_html( $message ), 'error' );
+		wp_safe_redirect( self::get_endpoint_url( $page ) );
+		exit;
+	}
+
+	/**
+	 * Get the URL of the My Account > stock notifications endpoint.
+	 *
+	 * @param int $page 1-indexed page to link to. Page 1 has no page segment.
+	 * @return string
+	 */
+	private static function get_endpoint_url( int $page = 1 ): string {
+		$value = $page > 1 ? (string) $page : '';
+		return \wc_get_endpoint_url( self::ENDPOINT, $value, \wc_get_page_permalink( 'myaccount' ) );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/NotificationQuery.php b/plugins/woocommerce/src/Internal/StockNotifications/NotificationQuery.php
index 4563a8e264d..b5d557c41c1 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/NotificationQuery.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/NotificationQuery.php
@@ -15,6 +15,37 @@ class NotificationQuery {
 	 * @return array The notifications.
 	 */
 	public static function get_notifications( array $args ): array {
+		$result = self::run_query( $args );
+
+		return is_array( $result ) ? $result : array();
+	}
+
+	/**
+	 * Count notifications matching the given filters.
+	 *
+	 * @param array $args Same filter args as {@see self::get_notifications()}, minus
+	 *                    the `return` / `limit` / `offset` keys (those are forced to
+	 *                    `count` / no-limit).
+	 * @return int Number of matching notifications.
+	 */
+	public static function count_notifications( array $args ): int {
+		$args['return'] = 'count';
+		unset( $args['limit'], $args['offset'] );
+
+		return (int) self::run_query( $args );
+	}
+
+	/**
+	 * Single dispatch site to the underlying data store's `query()` method.
+	 *
+	 * Centralised so the `WC_Data_Store::query()` PHPStan suppression in
+	 * `phpstan-baseline.neon` only needs to cover one call site.
+	 *
+	 * @param array $args Query args.
+	 * @return mixed Whatever the data store returns for the requested `return` mode
+	 *               (array of objects/ids, int for `count`).
+	 */
+	private static function run_query( array $args ) {
 		return \WC_Data_Store::load( 'stock_notification' )->query( $args );
 	}

diff --git a/plugins/woocommerce/src/Internal/StockNotifications/StockNotifications.php b/plugins/woocommerce/src/Internal/StockNotifications/StockNotifications.php
index f2c6162a563..cb3de9750d8 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/StockNotifications.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/StockNotifications.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Internal\StockNotifications\Privacy\PrivacyEraser;
 use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
 use Automattic\WooCommerce\Internal\StockNotifications\AsyncTasks\NotificationsProcessor;
 use Automattic\WooCommerce\Internal\StockNotifications\Admin\AdminManager;
+use Automattic\WooCommerce\Internal\StockNotifications\Frontend\MyAccountEndpoint;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\ProductPageIntegration;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\FormHandlerService;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\NotificationManagementService;
@@ -121,6 +122,7 @@ class StockNotifications implements RegisterHooksInterface {
 		$container->get( ProductPageIntegration::class );
 		$container->get( FormHandlerService::class );
 		$container->get( NotificationManagementService::class );
+		$container->get( MyAccountEndpoint::class );

 		if ( is_admin() ) {
 			$container->get( AdminManager::class );
diff --git a/plugins/woocommerce/templates/myaccount/stock-notifications.php b/plugins/woocommerce/templates/myaccount/stock-notifications.php
new file mode 100644
index 00000000000..b4df8681371
--- /dev/null
+++ b/plugins/woocommerce/templates/myaccount/stock-notifications.php
@@ -0,0 +1,148 @@
+<?php
+/**
+ * Back in stock notifications
+ *
+ * Shows the current user's back in stock notifications on the account page.
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/myaccount/stock-notifications.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 11.2.0
+ *
+ * @var array $notifications Array of Notification objects for the current user (one page).
+ * @var bool  $has_items     Whether there are any notifications to render.
+ * @var int   $current_page  1-indexed current page number.
+ * @var int   $total_pages   Total number of pages of notifications.
+ * @var int   $total_items   Total number of notifications across all pages.
+ * @var int   $per_page      Notifications shown per page.
+ */
+
+use Automattic\WooCommerce\Internal\StockNotifications\Frontend\MyAccountEndpoint;
+
+defined( 'ABSPATH' ) || exit;
+
+$wp_button_class = wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_theme_get_element_class_name( 'button' ) : '';
+
+/**
+ * Fires before the back in stock notifications table is rendered on My Account.
+ *
+ * @since 11.2.0
+ *
+ * @param bool $has_items Whether there are any notifications to render.
+ */
+do_action( 'woocommerce_before_account_customer_stock_notifications', $has_items );
+?>
+
+<?php if ( $has_items ) : ?>
+
+	<table class="woocommerce-customer-stock-notifications-table woocommerce-MyAccount-customerStockNotifications shop_table shop_table_responsive">
+		<thead>
+			<tr>
+				<th scope="col" class="woocommerce-customer-stock-notifications-table__header woocommerce-customer-stock-notifications-table__header-product"><span class="nobr"><?php esc_html_e( 'Product', 'woocommerce' ); ?></span></th>
+				<th scope="col" class="woocommerce-customer-stock-notifications-table__header woocommerce-customer-stock-notifications-table__header-date"><span class="nobr"><?php esc_html_e( 'Date signed up', 'woocommerce' ); ?></span></th>
+				<th scope="col" class="woocommerce-customer-stock-notifications-table__header woocommerce-customer-stock-notifications-table__header-actions"><span class="nobr"><?php esc_html_e( 'Actions', 'woocommerce' ); ?></span></th>
+			</tr>
+		</thead>
+		<tbody>
+		<?php foreach ( $notifications as $notification ) : ?>
+			<?php
+			$product_name = MyAccountEndpoint::get_display_product_name( $notification );
+			$permalink    = $notification->get_product_permalink();
+			$variation    = $notification->get_product_formatted_variation_list( true );
+			$date_created = $notification->get_date_created();
+
+			$cancel_label_name = '' !== $product_name ? $product_name : __( 'an unavailable product', 'woocommerce' );
+			if ( '' !== $variation ) {
+				$cancel_label_name .= ' ' . $variation;
+			}
+			/* translators: %s: product name, followed by its variation attributes when the sign-up is for a variation. */
+			$cancel_label = sprintf( __( 'Cancel stock notification for %s', 'woocommerce' ), $cancel_label_name );
+			?>
+			<tr class="woocommerce-customer-stock-notifications-table__row woocommerce-customer-stock-notifications-table__row--status-<?php echo esc_attr( (string) $notification->get_status() ); ?>">
+				<td class="woocommerce-customer-stock-notifications-table__cell woocommerce-customer-stock-notifications-table__cell-product" data-title="<?php esc_attr_e( 'Product', 'woocommerce' ); ?>">
+					<?php
+					/*
+					 * A deleted product still gets a row, rather than being skipped, so the
+					 * customer can see the sign-up exists and cancel it.
+					 */
+					?>
+					<?php if ( '' !== $product_name && '' !== $permalink ) : ?>
+						<a href="<?php echo esc_url( $permalink ); ?>"><?php echo esc_html( $product_name ); ?></a>
+					<?php elseif ( '' !== $product_name ) : ?>
+						<?php echo esc_html( $product_name ); ?>
+					<?php else : ?>
+						<?php esc_html_e( 'Product unavailable', 'woocommerce' ); ?>
+					<?php endif; ?>
+
+					<?php if ( '' !== $variation ) : ?>
+						<div class="description"><?php echo esc_html( $variation ); ?></div>
+					<?php endif; ?>
+				</td>
+				<td class="woocommerce-customer-stock-notifications-table__cell woocommerce-customer-stock-notifications-table__cell-date" data-title="<?php esc_attr_e( 'Date signed up', 'woocommerce' ); ?>">
+					<?php if ( $date_created ) : ?>
+						<time datetime="<?php echo esc_attr( $date_created->date( 'c' ) ); ?>"><?php echo esc_html( wc_format_datetime( $date_created ) ); ?></time>
+					<?php else : ?>
+						&mdash;
+					<?php endif; ?>
+				</td>
+				<td class="woocommerce-customer-stock-notifications-table__cell woocommerce-customer-stock-notifications-table__cell-actions actions" data-title="<?php esc_attr_e( 'Actions', 'woocommerce' ); ?>">
+					<?php if ( MyAccountEndpoint::is_cancellable( $notification ) ) : ?>
+						<form method="post" action="<?php echo esc_url( wc_get_endpoint_url( MyAccountEndpoint::ENDPOINT, '', wc_get_page_permalink( 'myaccount' ) ) ); ?>" class="woocommerce-customer-stock-notifications-cancel-form">
+							<input type="hidden" name="<?php echo esc_attr( MyAccountEndpoint::CANCEL_ACTION ); ?>" value="1" />
+							<input type="hidden" name="notification_id" value="<?php echo esc_attr( (string) $notification->get_id() ); ?>" />
+							<input type="hidden" name="notifications_page" value="<?php echo esc_attr( (string) $current_page ); ?>" />
+							<?php wp_nonce_field( MyAccountEndpoint::get_cancel_nonce_action( (int) $notification->get_id() ) ); ?>
+							<button type="submit" class="woocommerce-button button<?php echo esc_attr( $wp_button_class ); ?>" aria-label="<?php echo esc_attr( $cancel_label ); ?>"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></button>
+						</form>
+					<?php else : ?>
+						&mdash;
+					<?php endif; ?>
+				</td>
+			</tr>
+		<?php endforeach; ?>
+		</tbody>
+	</table>
+
+	<?php
+	/**
+	 * Fires before the stock notifications pagination is rendered on My Account.
+	 *
+	 * @since 11.2.0
+	 */
+	do_action( 'woocommerce_before_account_customer_stock_notifications_pagination' );
+	?>
+
+	<?php if ( $total_pages > 1 ) : ?>
+		<div class="woocommerce-pagination woocommerce-pagination--without-numbers woocommerce-Pagination">
+			<?php if ( 1 !== $current_page ) : ?>
+				<a class="woocommerce-button woocommerce-button--previous woocommerce-Button woocommerce-Button--previous button<?php echo esc_attr( $wp_button_class ); ?>" href="<?php echo esc_url( wc_get_endpoint_url( MyAccountEndpoint::ENDPOINT, (string) ( $current_page - 1 ), wc_get_page_permalink( 'myaccount' ) ) ); ?>"><?php esc_html_e( 'Previous', 'woocommerce' ); ?></a>
+			<?php endif; ?>
+
+			<?php if ( $total_pages !== $current_page ) : ?>
+				<a class="woocommerce-button woocommerce-button--next woocommerce-Button woocommerce-Button--next button<?php echo esc_attr( $wp_button_class ); ?>" href="<?php echo esc_url( wc_get_endpoint_url( MyAccountEndpoint::ENDPOINT, (string) ( $current_page + 1 ), wc_get_page_permalink( 'myaccount' ) ) ); ?>"><?php esc_html_e( 'Next', 'woocommerce' ); ?></a>
+			<?php endif; ?>
+		</div>
+	<?php endif; ?>
+
+<?php else : ?>
+
+	<?php wc_print_notice( esc_html__( "You haven't signed up for any back-in-stock notifications yet.", 'woocommerce' ) . ' <a class="woocommerce-Button wc-forward button' . esc_attr( $wp_button_class ) . '" href="' . esc_url( apply_filters( 'woocommerce_return_to_shop_redirect', wc_get_page_permalink( 'shop' ) ) ) . '">' . esc_html__( 'Browse products', 'woocommerce' ) . '</a>', 'notice' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment ?>
+
+<?php endif; ?>
+
+<?php
+/**
+ * Fires after the back in stock notifications table is rendered on My Account.
+ *
+ * @since 11.2.0
+ *
+ * @param bool $has_items Whether there were any notifications rendered.
+ */
+do_action( 'woocommerce_after_account_customer_stock_notifications', $has_items );
diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/my-account.spec.ts b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/my-account.spec.ts
new file mode 100644
index 00000000000..ecdcb6d2159
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/my-account.spec.ts
@@ -0,0 +1,271 @@
+/**
+ * External dependencies
+ */
+import { WC_API_PATH } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { expect, request, tags } from '../../fixtures/fixtures';
+import {
+	createOutOfStockProduct,
+	resetBISOptions,
+	setBISOptions,
+	signUpOnProductPage,
+	test,
+} from '../../utils/back-in-stock-notifications';
+import { logInFromMyAccount } from '../../utils/login';
+
+const MY_ACCOUNT_ENDPOINT = 'my-account/stock-notifications/';
+const TABLE = '.woocommerce-customer-stock-notifications-table';
+
+/**
+ * A customer account owned by a single test.
+ *
+ * The suite's shared customer accumulates sign-ups as the specs run, so row
+ * counts and the empty state would depend on execution order. Each test gets a
+ * throwaway account instead, and deletes it afterwards.
+ */
+type TestCustomer = {
+	id: number;
+	username: string;
+	password: string;
+};
+
+/**
+ * Create a customer account for one test to sign in as.
+ *
+ * @param {Object} restApi Authenticated REST client from the `restApi` fixture.
+ */
+async function createTestCustomer( restApi ): Promise< TestCustomer > {
+	const suffix = `${ Date.now() }-${ Math.floor( Math.random() * 1e6 ) }`;
+	const username = `bis-my-account-${ suffix }`;
+	const password = 'password';
+
+	const response = await restApi.post< { id: number } >(
+		`${ WC_API_PATH }/customers`,
+		{
+			email: `${ username }@woocommercecoree2etestsuite.com`,
+			username,
+			password,
+		}
+	);
+
+	return { id: response.data.id, username, password };
+}
+
+test.describe(
+	'Back in Stock Notifications — My Account',
+	{ tag: [ tags.SERVICES ] },
+	() => {
+		test.afterAll( async ( { baseURL } ) => {
+			await resetBISOptions( request, baseURL! );
+		} );
+
+		test.describe( 'Logged-in customer with signups', () => {
+			// Signed out, so each test can sign in as the account it owns.
+			test.use( { storageState: { cookies: [], origins: [] } } );
+
+			test( 'renders a pending and an active notification in the tab', async ( {
+				page,
+				baseURL,
+				product,
+				restApi,
+			} ) => {
+				const customer = await createTestCustomer( restApi );
+				// Single opt-in so the first signup lands as "active" straight away.
+				await setBISOptions( request, baseURL!, {
+					allowSignups: true,
+					doubleOptIn: false,
+				} );
+
+				const secondProduct = await createOutOfStockProduct( restApi );
+
+				try {
+					await page.goto( 'my-account/' );
+					await logInFromMyAccount(
+						page,
+						customer.username,
+						customer.password
+					);
+
+					// Row 1: signup while double opt-in is off → active.
+					await page.goto( product.permalink );
+					await signUpOnProductPage( page );
+					await expect(
+						page.getByText(
+							/You have successfully signed up|You have already joined this waitlist/i
+						)
+					).toBeVisible();
+
+					// Row 2: flip double opt-in on and sign up again → pending.
+					await setBISOptions( request, baseURL!, {
+						allowSignups: true,
+						doubleOptIn: true,
+					} );
+					await page.goto( secondProduct.permalink );
+					await signUpOnProductPage( page );
+
+					await page.goto( MY_ACCOUNT_ENDPOINT );
+
+					await expect(
+						page.getByRole( 'heading', {
+							name: 'Stock notifications',
+						} )
+					).toBeVisible();
+
+					// Both products appear as rows.
+					const table = page.locator( TABLE );
+					await expect( table ).toBeVisible();
+					await expect(
+						table.getByRole( 'link', {
+							name: product.name,
+							exact: true,
+						} )
+					).toBeVisible();
+					await expect(
+						table.getByRole( 'link', {
+							name: secondProduct.name,
+							exact: true,
+						} )
+					).toBeVisible();
+
+					// Exactly two rows — both signups are PENDING/ACTIVE so neither is filtered out.
+					await expect( table.locator( 'tbody tr' ) ).toHaveCount(
+						2
+					);
+				} finally {
+					await restApi.delete(
+						`${ WC_API_PATH }/products/${ secondProduct.id }`,
+						{ force: true }
+					);
+					await restApi.delete(
+						`${ WC_API_PATH }/customers/${ customer.id }`,
+						{ force: true }
+					);
+				}
+			} );
+
+			test( 'cancel click removes the row from the My Account list', async ( {
+				page,
+				baseURL,
+				product,
+				restApi,
+			} ) => {
+				const customer = await createTestCustomer( restApi );
+				await setBISOptions( request, baseURL!, {
+					allowSignups: true,
+					doubleOptIn: true,
+				} );
+
+				try {
+					await page.goto( 'my-account/' );
+					await logInFromMyAccount(
+						page,
+						customer.username,
+						customer.password
+					);
+
+					await page.goto( product.permalink );
+					await signUpOnProductPage( page );
+
+					await page.goto( MY_ACCOUNT_ENDPOINT );
+
+					const row = page.locator( `${ TABLE } tbody tr` ).filter( {
+						has: page.getByRole( 'link', {
+							name: product.name,
+							exact: true,
+						} ),
+					} );
+					await expect( row ).toBeVisible();
+
+					await row.getByRole( 'button', { name: 'Cancel' } ).click();
+
+					// The redirect after the POST lands us back on the same tab.
+					await expect(
+						page.getByRole( 'heading', {
+							name: 'Stock notifications',
+						} )
+					).toBeVisible();
+
+					// The cancelled row is filtered out — only PENDING/ACTIVE rows render.
+					await expect( row ).toHaveCount( 0 );
+
+					// And a notice confirms the cancellation.
+					await expect(
+						page.getByText(
+							`Back in stock notification for "${ product.name }" cancelled.`
+						)
+					).toBeVisible();
+				} finally {
+					await restApi.delete(
+						`${ WC_API_PATH }/customers/${ customer.id }`,
+						{ force: true }
+					);
+				}
+			} );
+		} );
+
+		test.describe( 'Logged-in customer with no signups', () => {
+			test.use( { storageState: { cookies: [], origins: [] } } );
+
+			test( 'renders the empty state and a catalog link', async ( {
+				page,
+				restApi,
+			} ) => {
+				const customer = await createTestCustomer( restApi );
+
+				try {
+					await page.goto( 'my-account/' );
+					await logInFromMyAccount(
+						page,
+						customer.username,
+						customer.password
+					);
+
+					await page.goto( MY_ACCOUNT_ENDPOINT );
+
+					await expect(
+						page.getByRole( 'heading', {
+							name: 'Stock notifications',
+						} )
+					).toBeVisible();
+
+					await expect(
+						page.getByText(
+							"You haven't signed up for any back-in-stock notifications yet."
+						)
+					).toBeVisible();
+
+					await expect(
+						page.getByRole( 'link', { name: 'Browse products' } )
+					).toBeVisible();
+
+					await expect( page.locator( TABLE ) ).toHaveCount( 0 );
+				} finally {
+					await restApi.delete(
+						`${ WC_API_PATH }/customers/${ customer.id }`,
+						{ force: true }
+					);
+				}
+			} );
+		} );
+
+		test.describe( 'Anonymous visitor', () => {
+			test.use( { storageState: { cookies: [], origins: [] } } );
+
+			test( 'is redirected to the login form', async ( { page } ) => {
+				await page.goto( MY_ACCOUNT_ENDPOINT );
+
+				// Standard WC behaviour: unauthenticated account endpoints show
+				// the login form on the My Account page.
+				await expect(
+					page.getByRole( 'heading', { name: 'Login' } )
+				).toBeVisible();
+				await expect(
+					page.getByLabel( 'Username or email address' )
+				).toBeVisible();
+			} );
+		} );
+	}
+);
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/account/functions.php b/plugins/woocommerce/tests/legacy/unit-tests/account/functions.php
index 587aa5a218e..f81806b99f7 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/account/functions.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/account/functions.php
@@ -73,12 +73,14 @@ class WC_Tests_Account_Functions extends WC_Unit_Test_Case {
 	public function test_wc_get_account_menu_items() {
 		$this->assertEquals(
 			array(
-				'dashboard'       => 'Dashboard',
-				'orders'          => 'Orders',
-				'downloads'       => 'Downloads',
-				'edit-address'    => 'Addresses',
-				'edit-account'    => 'Account details',
-				'customer-logout' => 'Log out',
+				'dashboard'           => 'Dashboard',
+				'orders'              => 'Orders',
+				'downloads'           => 'Downloads',
+				// Back in Stock Notifications is enabled for the whole suite in bootstrap.php.
+				'stock-notifications' => 'Stock notifications',
+				'edit-address'        => 'Addresses',
+				'edit-account'        => 'Account details',
+				'customer-logout'     => 'Log out',
 			),
 			wc_get_account_menu_items()
 		);
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-tests-wc-query.php b/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-tests-wc-query.php
index 37e8ea6b2af..bb395792cdc 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-tests-wc-query.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-tests-wc-query.php
@@ -60,6 +60,8 @@ class WC_Tests_WC_Query extends WC_Unit_Test_Case {
 			'add-payment-method'         => 'add-payment-method',
 			'delete-payment-method'      => 'delete-payment-method',
 			'set-default-payment-method' => 'set-default-payment-method',
+			// Back in Stock Notifications is enabled for the whole suite in bootstrap.php.
+			'stock-notifications'        => 'stock-notifications',
 		);
 		$this->assertEquals( $expected, $default_vars );

diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/MyAccountEndpointTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/MyAccountEndpointTests.php
new file mode 100644
index 00000000000..d71ef059c20
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/MyAccountEndpointTests.php
@@ -0,0 +1,598 @@
+<?php
+/**
+ * MyAccountEndpointTests class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Frontend;
+
+use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource;
+use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
+use Automattic\WooCommerce\Internal\StockNotifications\Factory;
+use Automattic\WooCommerce\Internal\StockNotifications\Frontend\MyAccountEndpoint;
+use Automattic\WooCommerce\Internal\StockNotifications\Notification;
+use WC_Helper_Product;
+
+/**
+ * Tests for the customer-facing MyAccount back-in-stock notifications endpoint.
+ */
+class MyAccountEndpointTests extends \WC_Unit_Test_Case {
+
+	/**
+	 * Location passed to the last suppressed redirect, or null if none happened.
+	 *
+	 * @var string|null
+	 */
+	private $redirect_location = null;
+
+	/**
+	 * Set up the test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		\wc_clear_notices();
+		$this->redirect_location = null;
+		add_filter( 'wp_redirect', array( $this, 'capture_redirect' ) );
+	}
+
+	/**
+	 * Tear down the test.
+	 */
+	public function tearDown(): void {
+		remove_filter( 'wp_redirect', array( $this, 'capture_redirect' ) );
+		$this->redirect_location = null;
+		\wc_clear_notices();
+		\wp_set_current_user( 0 );
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$_POST = array();
+		global $wp;
+		if ( isset( $wp->query_vars[ MyAccountEndpoint::ENDPOINT ] ) ) {
+			unset( $wp->query_vars[ MyAccountEndpoint::ENDPOINT ] );
+		}
+		parent::tearDown();
+	}
+
+	/**
+	 * Record the redirect target and abort the request the way `exit` would.
+	 *
+	 * PHPUnit has already sent output by the time a test runs, so letting
+	 * `wp_safe_redirect()` reach `header()` raises "headers already sent", and
+	 * the `exit` that follows it in production would end the test run. Throwing
+	 * from the filter stands in for both.
+	 *
+	 * The target is recorded in `$redirect_location` rather than in the exception
+	 * message, which phpcs requires to be escaped.
+	 *
+	 * @param string $location The redirect target.
+	 * @return never
+	 * @throws \RuntimeException Always, to stand in for the `exit`.
+	 */
+	public function capture_redirect( $location ) {
+		$this->redirect_location = (string) $location;
+		throw new \RuntimeException( 'Redirected.' );
+	}
+
+	/**
+	 * Create a notification owned by the given user.
+	 *
+	 * @param int    $user_id User id.
+	 * @param string $status  Notification status.
+	 * @return Notification
+	 */
+	private function create_notification( int $user_id, string $status = NotificationStatus::ACTIVE ): Notification {
+		$user    = \get_user_by( 'id', $user_id );
+		$product = WC_Helper_Product::create_simple_product();
+
+		$notification = new Notification();
+		$notification->set_product_id( $product->get_id() );
+		$notification->set_user_id( $user_id );
+		$notification->set_user_email( $user ? $user->user_email : 'nobody@example.com' );
+		$notification->set_status( $status );
+		$notification->save();
+
+		return $notification;
+	}
+
+	/**
+	 * Run the cancel handler expecting it to redirect, and assert the target.
+	 *
+	 * `capture_redirect()` throws in place of the `exit` that follows the redirect
+	 * in production, so the call has to be wrapped.
+	 *
+	 * @param int $expected_page 1-indexed page the redirect should land on.
+	 */
+	private function run_cancel_expecting_redirect( int $expected_page = 1 ): void {
+		try {
+			( new MyAccountEndpoint() )->maybe_handle_cancel();
+			$this->fail( 'Expected the cancel handler to redirect.' );
+		} catch ( \RuntimeException $e ) {
+			unset( $e );
+		}
+
+		$this->assertSame(
+			\wc_get_endpoint_url( MyAccountEndpoint::ENDPOINT, $expected_page > 1 ? (string) $expected_page : '', \wc_get_page_permalink( 'myaccount' ) ),
+			$this->redirect_location
+		);
+	}
+
+	/**
+	 * The row label uses the parent title for a variation, so the attributes are
+	 * not repeated by both the name and the variation list rendered beneath it.
+	 */
+	public function test_get_display_product_name_uses_parent_title_for_variations(): void {
+		$variable   = WC_Helper_Product::create_variation_product();
+		$variations = $variable->get_children();
+		$this->assertNotEmpty( $variations );
+
+		$variation = wc_get_product( $variations[0] );
+		$this->assertInstanceOf( \WC_Product_Variation::class, $variation );
+
+		$notification = new Notification();
+		$notification->set_product_id( $variation->get_id() );
+		$notification->set_user_email( 'nobody@example.com' );
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->save();
+
+		$this->assertSame( $variable->get_title(), MyAccountEndpoint::get_display_product_name( $notification ) );
+		// The Notification getter still returns the attribute-carrying variation name.
+		$this->assertSame( $variation->get_name(), $notification->get_product_name() );
+	}
+
+	/**
+	 * A notification whose product no longer exists has no name to show.
+	 */
+	public function test_get_display_product_name_is_empty_without_a_product(): void {
+		$notification = new Notification();
+		$notification->set_product_id( 999999 );
+		$notification->set_user_email( 'nobody@example.com' );
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->save();
+
+		$this->assertSame( '', MyAccountEndpoint::get_display_product_name( $notification ) );
+	}
+
+	/**
+	 * The endpoint returns the logged-in user's notifications.
+	 */
+	public function test_get_current_user_notifications_returns_users_notifications(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+
+		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+
+		$endpoint = new MyAccountEndpoint();
+		$results  = $endpoint->get_current_user_notifications_page( 1, MyAccountEndpoint::DEFAULT_PER_PAGE )['notifications'];
+
+		$this->assertCount( 1, $results );
+		$this->assertSame( $notification->get_id(), $results[0]->get_id() );
+		$this->assertSame( $user_id, (int) $results[0]->get_user_id() );
+	}
+
+	/**
+	 * User A cannot see user B's notifications via the endpoint helper.
+	 */
+	public function test_get_current_user_notifications_scopes_to_current_user(): void {
+		$user_a = $this->factory->user->create( array( 'role' => 'customer' ) );
+		$user_b = $this->factory->user->create( array( 'role' => 'customer' ) );
+
+		$this->create_notification( $user_a, NotificationStatus::ACTIVE );
+		$b_notification = $this->create_notification( $user_b, NotificationStatus::PENDING );
+
+		\wp_set_current_user( $user_b );
+		$endpoint = new MyAccountEndpoint();
+		$results  = $endpoint->get_current_user_notifications_page( 1, MyAccountEndpoint::DEFAULT_PER_PAGE )['notifications'];
+
+		$this->assertCount( 1, $results );
+		$this->assertSame( $b_notification->get_id(), $results[0]->get_id() );
+	}
+
+	/**
+	 * Anonymous visitors get an empty result set without querying by user-supplied ids.
+	 */
+	public function test_get_current_user_notifications_returns_empty_for_anonymous(): void {
+		\wp_set_current_user( 0 );
+
+		$endpoint = new MyAccountEndpoint();
+		$results  = $endpoint->get_current_user_notifications_page( 1, MyAccountEndpoint::DEFAULT_PER_PAGE )['notifications'];
+
+		$this->assertSame( array(), $results );
+	}
+
+	/**
+	 * The empty state fires when the user has zero signups.
+	 */
+	public function test_get_current_user_notifications_empty_state(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+
+		$endpoint = new MyAccountEndpoint();
+		$results  = $endpoint->get_current_user_notifications_page( 1, MyAccountEndpoint::DEFAULT_PER_PAGE )['notifications'];
+
+		$this->assertSame( array(), $results );
+	}
+
+	/**
+	 * Page size is honoured and total counts reflect the full set, not just the page.
+	 */
+	public function test_get_current_user_notifications_page_paginates(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+
+		// 7 notifications, fetch page 2 with per_page=3 → expect rows 4-6.
+		$created = array();
+		for ( $i = 0; $i < 7; $i++ ) {
+			$created[] = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+		}
+		// `id` DESC ordering — newest-first — so page 1 = ids[6..4], page 2 = ids[3..1], page 3 = id[0].
+		$ids_desc = array_reverse( array_map( static fn ( $n ) => $n->get_id(), $created ) );
+
+		$endpoint = new MyAccountEndpoint();
+		$page     = $endpoint->get_current_user_notifications_page( 2, 3 );
+
+		$this->assertSame( 7, $page['total_items'] );
+		$this->assertSame( 3, $page['total_pages'] );
+		$this->assertSame( 2, $page['current_page'] );
+		$this->assertCount( 3, $page['notifications'] );
+		$this->assertSame( array_slice( $ids_desc, 3, 3 ), array_map( static fn ( $n ) => $n->get_id(), $page['notifications'] ) );
+	}
+
+	/**
+	 * SENT and CANCELLED notifications are filtered out — only ACTIVE/PENDING render in the My Account view.
+	 */
+	public function test_get_current_user_notifications_page_excludes_sent_and_cancelled(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+
+		$active  = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+		$pending = $this->create_notification( $user_id, NotificationStatus::PENDING );
+		$this->create_notification( $user_id, NotificationStatus::SENT );
+		$this->create_notification( $user_id, NotificationStatus::CANCELLED );
+
+		$endpoint = new MyAccountEndpoint();
+		$page     = $endpoint->get_current_user_notifications_page( 1, MyAccountEndpoint::DEFAULT_PER_PAGE );
+
+		$this->assertSame( 2, $page['total_items'] );
+		$visible_ids = array_map( static fn ( $n ) => $n->get_id(), $page['notifications'] );
+		$this->assertEqualsCanonicalizing( array( $active->get_id(), $pending->get_id() ), $visible_ids );
+	}
+
+	/**
+	 * Out-of-range page numbers clamp to the last page so a stale link doesn't render an empty table.
+	 */
+	public function test_get_current_user_notifications_page_clamps_out_of_range(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+
+		for ( $i = 0; $i < 5; $i++ ) {
+			$this->create_notification( $user_id, NotificationStatus::ACTIVE );
+		}
+
+		$endpoint = new MyAccountEndpoint();
+		// Per-page 2 → 3 pages exist (rows 1-2, 3-4, 5). Asking for page 99 should clamp to 3.
+		$page = $endpoint->get_current_user_notifications_page( 99, 2 );
+
+		$this->assertSame( 3, $page['current_page'] );
+		$this->assertCount( 1, $page['notifications'] );
+	}
+
+	/**
+	 * A valid nonce for the notification owner cancels the notification.
+	 */
+	public function test_cancel_with_valid_nonce_sets_status_cancelled(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+
+		$this->simulate_cancel_request( $notification->get_id(), true );
+
+		$this->run_cancel_expecting_redirect();
+
+		$this->assertEmpty( \wc_get_notices( 'error' ) );
+
+		$updated = Factory::get_notification( $notification->get_id() );
+		$this->assertInstanceOf( Notification::class, $updated );
+		$this->assertSame( NotificationStatus::CANCELLED, $updated->get_status() );
+		$this->assertSame( NotificationCancellationSource::USER, $updated->get_cancellation_source() );
+	}
+
+	/**
+	 * A notification that is already sent or cancelled can no longer be cancelled,
+	 * even with a valid nonce — the My Account view never offers the button for it.
+	 * The customer gets an error notice rather than a page that looks unchanged.
+	 *
+	 * @testWith ["sent"]
+	 *           ["cancelled"]
+	 *
+	 * @param string $status Non-cancellable notification status.
+	 */
+	public function test_cancel_ignored_for_non_cancellable_status( string $status ): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+		$notification = $this->create_notification( $user_id, $status );
+
+		$this->simulate_cancel_request( $notification->get_id(), true );
+
+		$this->run_cancel_expecting_redirect();
+
+		$this->assertCount( 1, \wc_get_notices( 'error' ) );
+
+		$updated = Factory::get_notification( $notification->get_id() );
+		$this->assertInstanceOf( Notification::class, $updated );
+		$this->assertSame( $status, $updated->get_status() );
+	}
+
+	/**
+	 * The cancellable statuses are the ones the My Account view lists.
+	 */
+	public function test_is_cancellable_matches_listed_statuses(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+
+		$this->assertTrue( MyAccountEndpoint::is_cancellable( $this->create_notification( $user_id, NotificationStatus::PENDING ) ) );
+		$this->assertTrue( MyAccountEndpoint::is_cancellable( $this->create_notification( $user_id, NotificationStatus::ACTIVE ) ) );
+		$this->assertFalse( MyAccountEndpoint::is_cancellable( $this->create_notification( $user_id, NotificationStatus::SENT ) ) );
+		$this->assertFalse( MyAccountEndpoint::is_cancellable( $this->create_notification( $user_id, NotificationStatus::CANCELLED ) ) );
+	}
+
+	/**
+	 * An invalid nonce does not modify the notification. Nonces expire after 12-24
+	 * hours, so a stale tab has to surface a recoverable error rather than nothing.
+	 */
+	public function test_cancel_with_invalid_nonce_does_not_cancel(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+
+		$this->simulate_cancel_request( $notification->get_id(), false );
+
+		$this->run_cancel_expecting_redirect();
+
+		$this->assertCount( 1, \wc_get_notices( 'error' ) );
+
+		$updated = Factory::get_notification( $notification->get_id() );
+		$this->assertInstanceOf( Notification::class, $updated );
+		$this->assertSame( NotificationStatus::ACTIVE, $updated->get_status() );
+	}
+
+	/**
+	 * A nonce scoped to notification A cannot be replayed on notification B.
+	 */
+	public function test_cancel_nonce_for_other_notification_does_not_validate(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+
+		$notification_a = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+		$notification_b = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+
+		// Nonce was minted against A's id, but we POST it alongside B's id.
+		$nonce = \wp_create_nonce( MyAccountEndpoint::get_cancel_nonce_action( $notification_a->get_id() ) );
+
+		$this->set_endpoint_query_var();
+		// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.WP.GlobalVariablesOverride.Prohibited
+		$_POST = array(
+			MyAccountEndpoint::CANCEL_ACTION => '1',
+			'notification_id'                => (string) $notification_b->get_id(),
+			'_wpnonce'                       => $nonce,
+		);
+		// phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		$this->run_cancel_expecting_redirect();
+
+		$this->assertCount( 1, \wc_get_notices( 'error' ) );
+
+		$updated_a = Factory::get_notification( $notification_a->get_id() );
+		$updated_b = Factory::get_notification( $notification_b->get_id() );
+
+		$this->assertInstanceOf( Notification::class, $updated_a );
+		$this->assertInstanceOf( Notification::class, $updated_b );
+		$this->assertSame( NotificationStatus::ACTIVE, $updated_a->get_status() );
+		$this->assertSame( NotificationStatus::ACTIVE, $updated_b->get_status() );
+	}
+
+	/**
+	 * A cancel for a notification that no longer exists reports an error rather than
+	 * re-rendering the page unchanged.
+	 */
+	public function test_cancel_for_missing_notification_reports_an_error(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+		$id           = $notification->get_id();
+
+		$this->simulate_cancel_request( $id, true );
+		$notification->delete( true );
+
+		$this->run_cancel_expecting_redirect();
+
+		$this->assertCount( 1, \wc_get_notices( 'error' ) );
+	}
+
+	/**
+	 * A failed database write reports an error instead of telling the customer the
+	 * notification was cancelled.
+	 */
+	public function test_cancel_reports_an_error_when_the_save_fails(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+
+		$this->simulate_cancel_request( $notification->get_id(), true );
+
+		$neutralize_update = array( $this, 'neutralize_notification_update' );
+		add_filter( 'query', $neutralize_update );
+		try {
+			$this->run_cancel_expecting_redirect();
+		} finally {
+			remove_filter( 'query', $neutralize_update );
+		}
+
+		$this->assertCount( 1, \wc_get_notices( 'error' ) );
+		$this->assertEmpty( \wc_get_notices( 'success' ) );
+
+		$reloaded = Factory::get_notification( $notification->get_id() );
+		$this->assertInstanceOf( Notification::class, $reloaded );
+		$this->assertSame( NotificationStatus::ACTIVE, $reloaded->get_status() );
+	}
+
+	/**
+	 * Make an UPDATE against the stock notifications table match no rows, so the data
+	 * store reports the failure the way a real database error would.
+	 *
+	 * @param string $query The query about to run.
+	 * @return string
+	 */
+	public function neutralize_notification_update( $query ) {
+		global $wpdb;
+
+		if ( is_string( $query ) && 0 === stripos( $query, 'UPDATE' ) && false !== stripos( $query, $wpdb->prefix . 'wc_stock_notifications' ) ) {
+			return $query . ' AND 1 = 0';
+		}
+
+		return $query;
+	}
+
+	/**
+	 * User A cannot cancel user B's notification even when the nonce validates against B's action name
+	 * (WordPress nonces bind to the current user, so this effectively asserts the ownership check).
+	 *
+	 * This one stays silent: an error notice would confirm that the id exists.
+	 */
+	public function test_cancel_does_not_touch_other_users_notification(): void {
+		$user_a = $this->factory->user->create( array( 'role' => 'customer' ) );
+		$user_b = $this->factory->user->create( array( 'role' => 'customer' ) );
+
+		$notification_b = $this->create_notification( $user_b, NotificationStatus::ACTIVE );
+
+		// User A logs in and tries to cancel B's notification.
+		\wp_set_current_user( $user_a );
+		$this->simulate_cancel_request( $notification_b->get_id(), true );
+
+		( new MyAccountEndpoint() )->maybe_handle_cancel();
+
+		$this->assertNull( $this->redirect_location );
+		$this->assertEmpty( \wc_get_notices( 'error' ) );
+
+		$updated_b = Factory::get_notification( $notification_b->get_id() );
+		$this->assertInstanceOf( Notification::class, $updated_b );
+		$this->assertSame( NotificationStatus::ACTIVE, $updated_b->get_status() );
+	}
+
+	/**
+	 * An anonymous POST with a cancel payload is silently dropped.
+	 */
+	public function test_cancel_ignored_when_anonymous(): void {
+		$user_id      = $this->factory->user->create( array( 'role' => 'customer' ) );
+		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+
+		\wp_set_current_user( 0 );
+		$this->simulate_cancel_request( $notification->get_id(), true );
+
+		( new MyAccountEndpoint() )->maybe_handle_cancel();
+
+		$updated = Factory::get_notification( $notification->get_id() );
+		$this->assertInstanceOf( Notification::class, $updated );
+		$this->assertSame( NotificationStatus::ACTIVE, $updated->get_status() );
+	}
+
+	/**
+	 * The menu filter adds the Stock notifications item.
+	 */
+	public function test_menu_item_is_registered(): void {
+		$endpoint = new MyAccountEndpoint();
+
+		$items = $endpoint->register_menu_item(
+			array(
+				'dashboard'       => 'Dashboard',
+				'orders'          => 'Orders',
+				'downloads'       => 'Downloads',
+				'customer-logout' => 'Log out',
+			),
+			array()
+		);
+
+		$this->assertArrayHasKey( MyAccountEndpoint::ENDPOINT, $items );
+		$this->assertSame( 'Stock notifications', $items[ MyAccountEndpoint::ENDPOINT ] );
+
+		// Order preserved: after downloads, logout stays last.
+		$keys = array_keys( $items );
+		$this->assertSame( 'customer-logout', end( $keys ) );
+		$this->assertGreaterThan(
+			(int) array_search( 'downloads', $keys, true ),
+			(int) array_search( MyAccountEndpoint::ENDPOINT, $keys, true )
+		);
+	}
+
+	/**
+	 * The query var filter registers the endpoint slug.
+	 */
+	public function test_query_var_is_registered(): void {
+		$endpoint = new MyAccountEndpoint();
+		$vars     = $endpoint->register_query_var( array( 'orders' => 'orders' ) );
+
+		$this->assertArrayHasKey( MyAccountEndpoint::ENDPOINT, $vars );
+		$this->assertSame( MyAccountEndpoint::ENDPOINT, $vars[ MyAccountEndpoint::ENDPOINT ] );
+	}
+
+	/**
+	 * @testdox Should return the customer to the page they cancelled from.
+	 */
+	public function test_cancel_returns_to_the_page_it_was_submitted_from(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+
+		$this->simulate_cancel_request( $notification->get_id(), true, 3 );
+
+		$this->run_cancel_expecting_redirect( 3 );
+	}
+
+	/**
+	 * @testdox Should keep the customer on their page when the cancel fails.
+	 */
+	public function test_failed_cancel_returns_to_the_page_it_was_submitted_from(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+
+		$this->simulate_cancel_request( $notification->get_id(), false, 2 );
+
+		$this->run_cancel_expecting_redirect( 2 );
+
+		$this->assertNotEmpty( \wc_get_notices( 'error' ), 'An invalid nonce should queue an error notice.' );
+	}
+
+	/**
+	 * Helper: fake the global state needed for `maybe_handle_cancel()` to proceed past guards.
+	 *
+	 * @param int  $notification_id Notification id.
+	 * @param bool $valid_nonce     Whether to mint a nonce that validates for this id.
+	 * @param int  $page            1-indexed page the cancel was submitted from.
+	 */
+	private function simulate_cancel_request( int $notification_id, bool $valid_nonce, int $page = 1 ): void {
+		$this->set_endpoint_query_var();
+
+		$nonce = $valid_nonce
+			? \wp_create_nonce( MyAccountEndpoint::get_cancel_nonce_action( $notification_id ) )
+			: 'clearly-not-a-valid-nonce';
+
+		// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.WP.GlobalVariablesOverride.Prohibited
+		$_POST = array(
+			MyAccountEndpoint::CANCEL_ACTION => '1',
+			'notification_id'                => (string) $notification_id,
+			'notifications_page'             => (string) $page,
+			'_wpnonce'                       => $nonce,
+		);
+		// phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.WP.GlobalVariablesOverride.Prohibited
+	}
+
+	/**
+	 * Helper: pretend we are on the My Account > BIS endpoint.
+	 */
+	private function set_endpoint_query_var(): void {
+		global $wp;
+		if ( ! $wp instanceof \WP ) {
+			$wp = new \WP(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		}
+		$wp->query_vars[ MyAccountEndpoint::ENDPOINT ] = '';
+	}
+}