Commit cde534eb781 for woocommerce

commit cde534eb78131d66499f1f2c39ce71ccfdbe4b92
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date:   Wed Aug 26 11:09:46 2026 +0100

    Move Back in Stock Notifications activation from wp-config flag to a Features setting (#64490)

    * Move BIS activation from wp-config flag to a Features setting

    The Back in Stock Notifications alpha was gated behind a
    WOOCOMMERCE_BIS_ALPHA_ENABLED wp-config constant, which means store
    owners can only opt in by editing wp-config.php — a non-starter for
    the wider alpha audience we want to reach. Move the gate to a normal
    WooCommerce feature toggle so it shows up in the standard place
    (WooCommerce → Settings → Advanced → Features) and merchants can flip
    it on/off from wp-admin.

    - Register a `customer_stock_notifications` feature in
      FeaturesController, marked experimental + alpha in the description so
      the feature card sits under the Experimental heading and the wording
      is unambiguous about API/data stability.
    - Replace the constant check in `WooCommerce::init_classes` with
      `FeaturesUtil::feature_is_enabled( 'customer_stock_notifications' )`,
      and drop the now-unused `Constants` import from class-woocommerce.php.
    - Drop the schema gate in `StockNotificationsDataStore::get_database_schema`
      so the BIS tables are always created at install/upgrade time.
      Otherwise enabling the feature mid-life would leave a no-op feature
      with no tables to write to until the next plugin upgrade. Empty tables
      are cheap; the feature flag now governs UI/behavior only.
    - Update the legacy test bootstrap to set
      `woocommerce_feature_customer_stock_notifications_enabled = yes`
      instead of defining the now-removed constant.

    Stores currently relying on the constant will need to re-enable the
    feature once via the Features screen after upgrading. Acceptable
    because the audience is alpha-flagged users only and the upgrade
    notice can mention this.

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

    * Drop alpha wording from BIS feature card

    The "(alpha)" suffix and "Alpha — interfaces, data, and behavior may
    change" caveat are coming off — present in the original draft but not
    needed on the merchant-facing card.

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

    * fix: gate stock notifications on init instead of at plugin load

    * fix: migrate BIS alpha constant opt-in to the feature toggle option

    * fix: handle stock notifications feature toggle setup and teardown

    * refactor: drop redundant skip_compatibility_checks from BIS feature def

    * fix: harden BIS feature toggle hook callback and data store gating

    * docs: tighten BIS feature toggle code comments

    * refactor: make BIS feature option name a single source of truth

    * docs: correct why the BIS data store gate re-checks the option

    * fix: overwrite the seeded feature option in the BIS alpha migration

    ---------

    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/bis-features-screen-toggle b/plugins/woocommerce/changelog/bis-features-screen-toggle
new file mode 100644
index 00000000000..e31bc92d67b
--- /dev/null
+++ b/plugins/woocommerce/changelog/bis-features-screen-toggle
@@ -0,0 +1,4 @@
+Significance: minor
+Type: dev
+
+Move Back in Stock Notifications activation from the WOOCOMMERCE_BIS_ALPHA_ENABLED wp-config constant to a normal feature toggle at WooCommerce → Settings → Advanced → Features → Experimental.
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 86cfde7f0f9..2d9979b09f0 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -350,6 +350,9 @@ class WC_Install {
 		'11.1.0-1' => array(
 			'wc_update_11101_remove_deprecated_variation_gallery_option',
 		),
+		'11.2.0'   => array(
+			'wc_update_1120_migrate_stock_notifications_alpha_constant',
+		),
 	);

 	/**
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index a6d71af5628..7cda07c2945 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -46,8 +46,6 @@ use Automattic\WooCommerce\Internal\Caches\ProductVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\Caches\OrdersVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\Caches\TaxRateVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\CustomerEmailVerification\CustomerEmailVerification;
-use Automattic\WooCommerce\Internal\StockNotifications\StockNotifications;
-use Automattic\Jetpack\Constants;

 /**
  * Main WooCommerce Class.
@@ -412,11 +410,6 @@ final class WooCommerce {
 		$container->get( CustomerEmailVerification::class );
 		$container->get( OrderLogsCleanupHelper::class );

-		// Feature flags.
-		if ( Constants::is_true( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' ) ) {
-			$container->get( StockNotifications::class );
-		}
-
 		/**
 		 * These classes have a register method for attaching hooks.
 		 */
@@ -440,6 +433,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\Orders\PointOfSaleEmailHandler::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\POS\POSController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\StockNotifications\StockNotifications::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\ScheduledSalePriceReconciler::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalController::class )->register();

diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 06b5fda6f14..3b8402817a6 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -18,6 +18,7 @@

 defined( 'ABSPATH' ) || exit;

+use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Admin\Notes\Note;
 use Automattic\WooCommerce\Admin\Notes\Notes;
 use Automattic\WooCommerce\Database\Migrations\MigrationHelper;
@@ -37,6 +38,7 @@ use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
 use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
 use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
 use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchronize as Download_Directories_Sync;
+use Automattic\WooCommerce\Internal\StockNotifications\StockNotifications;
 use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
 use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
 use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
@@ -3636,3 +3638,28 @@ function wc_update_1110_flush_product_count_cache() {
 		( new \Automattic\WooCommerce\Caches\ProductCountCache() )->flush( 'product' );
 	}
 }
+
+/**
+ * Migrate the Back in Stock Notifications alpha opt-in from the
+ * WOOCOMMERCE_BIS_ALPHA_ENABLED constant to the feature toggle.
+ *
+ * The option is written with update_option() rather than add_option() because
+ * WC_Install::create_options() runs first and has already seeded every Features
+ * screen checkbox with its default. No store can have chosen 'no' deliberately:
+ * the toggle does not exist before this release.
+ *
+ * Writing the option fires 'updated_option', which FeaturesController turns into
+ * FEATURE_ENABLED_CHANGED_ACTION, so the feature's own activation side effects
+ * (the data retention task and the rewrite rules flush) run from there.
+ *
+ * @since 11.2.0
+ *
+ * @return void
+ */
+function wc_update_1120_migrate_stock_notifications_alpha_constant() {
+	if ( ! Constants::is_true( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' ) ) {
+		return;
+	}
+
+	update_option( StockNotifications::ENABLE_OPTION_NAME, 'yes', true );
+}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index ed977d05612..0fb4f327e79 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -67500,12 +67500,6 @@ parameters:
 			count: 1
 			path: src/Internal/StockNotifications/Privacy/PrivacyEraser.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\StockNotifications\\StockNotifications\:\:init_hooks\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Internal/StockNotifications/StockNotifications.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Internal\\StockNotifications\\StockNotifications\:\:on_install_or_update\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
index 38b0ede624b..3d94d603c85 100644
--- a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
@@ -7,7 +7,6 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Internal\DataStores\StockNotifications;

-use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
@@ -73,11 +72,6 @@ class StockNotificationsDataStore implements \WC_Object_Data_Store_Interface {
 	 * @return string
 	 */
 	public function get_database_schema(): string {
-
-		if ( ! Constants::is_true( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' ) ) {
-			return '';
-		}
-
 		global $wpdb;

 		$collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index d61a80d585b..e067fb01c69 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -18,6 +18,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableControlle
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
 use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
 use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\StockNotifications\StockNotifications;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\Utilities\ArrayUtil;
 use Automattic\WooCommerce\Utilities\PluginUtil;
@@ -709,6 +710,18 @@ class FeaturesController {
 				'is_experimental'              => true,
 				'disable_ui'                   => false,
 			),
+			StockNotifications::FEATURE_NAME       => array(
+				'name'                         => __( 'Back in Stock Notifications', 'woocommerce' ),
+				'description'                  => __(
+					'Allow customers to sign up to receive an email when an out-of-stock product is back in stock.',
+					'woocommerce'
+				),
+				'option_key'                   => StockNotifications::ENABLE_OPTION_NAME,
+				'enabled_by_default'           => false,
+				'is_experimental'              => true,
+				'disable_ui'                   => false,
+				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+			),
 			BlockEditorUnifiedAssets::FEATURE_NAME => array(
 				'name'                         => __( 'Unified block editor assets', 'woocommerce' ),
 				'description'                  => __( 'Load WooCommerce block editor scripts and styles from shared bundles to improve editor performance.', 'woocommerce' ),
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/StockNotifications.php b/plugins/woocommerce/src/Internal/StockNotifications/StockNotifications.php
index 8129ab721ec..f2c6162a563 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/StockNotifications.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/StockNotifications.php
@@ -5,6 +5,8 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Internal\StockNotifications;

 use Automattic\WooCommerce\Internal\DataStores\StockNotifications\StockNotificationsDataStore;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
 use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailActionController;
 use Automattic\WooCommerce\Internal\StockNotifications\StockSyncController;
 use Automattic\WooCommerce\Internal\StockNotifications\Privacy\PrivacyEraser;
@@ -14,36 +16,98 @@ use Automattic\WooCommerce\Internal\StockNotifications\Admin\AdminManager;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\ProductPageIntegration;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\FormHandlerService;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\NotificationManagementService;
+use Automattic\WooCommerce\Utilities\FeaturesUtil;

 /**
  * The controller for the stock notifications.
  */
-class StockNotifications {
+class StockNotifications implements RegisterHooksInterface {

 	/**
-	 * Initialize the controller.
+	 * The feature that gates the whole Back in Stock Notifications experience.
 	 */
-	public function __construct() {
-		add_action( 'plugins_loaded', array( $this, 'init_hooks' ) );
+	public const FEATURE_NAME = 'customer_stock_notifications';
+
+	/**
+	 * The option that stores the feature's on/off state.
+	 *
+	 * Declared as `option_key` in the feature definition, so this constant is the
+	 * single source of truth rather than a copy of a derived name.
+	 */
+	public const ENABLE_OPTION_NAME = 'woocommerce_feature_customer_stock_notifications_enabled';
+
+	/**
+	 * Register the hooks that must exist regardless of the feature's state.
+	 *
+	 * The services are wired up in `maybe_init_services()`, behind the feature check.
+	 * The feature-change listener cannot live there: during the request that turns the
+	 * feature on it is still off, so it would never observe its own activation.
+	 *
+	 * @internal
+	 */
+	public function register(): void {
+		add_action( 'init', array( $this, 'maybe_init_services' ), 1 );
 		add_action( 'woocommerce_installed', array( $this, 'on_install_or_update' ) );
+		add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'on_feature_enabled_changed' ), 10, 2 );
 	}

 	/**
-	 * Handle the WooCommerce installation event.
-	 *
-	 * This method is called when WooCommerce is installed or updated.
-	 * It initializes the data retention controller to set up necessary tasks.
+	 * Set up the data retention tasks when WooCommerce is installed or updated.
 	 */
 	public function on_install_or_update() {
+		if ( ! FeaturesUtil::feature_is_enabled( self::FEATURE_NAME ) ) {
+			return;
+		}
+
 		wc_get_container()->get( DataRetentionController::class )->on_woo_install_or_update();
 	}

 	/**
-	 * Register hooks and services.
+	 * Set up or tear down the feature's side effects when it is toggled.
+	 *
+	 * Toggling from the Features screen fires no install event, so the daily data
+	 * retention task and the rewrite rules have to be handled here.
+	 *
+	 * Params are untyped and coerced in the body because any third-party code can
+	 * fire this action with fewer or differently-typed arguments.
+	 *
+	 * @param mixed $feature_id The feature that changed.
+	 * @param mixed $enabled    Whether the feature is now enabled.
+	 *
+	 * @internal
+	 */
+	public function on_feature_enabled_changed( $feature_id, $enabled = false ): void {
+		if ( self::FEATURE_NAME !== $feature_id ) {
+			return;
+		}
+
+		$enabled = filter_var( $enabled, FILTER_VALIDATE_BOOLEAN );
+
+		// The My Account endpoint appears or disappears with the feature.
+		update_option( 'woocommerce_queue_flush_rewrite_rules', 'yes' );
+
+		$data_retention_controller = wc_get_container()->get( DataRetentionController::class );
+
+		if ( $enabled ) {
+			$data_retention_controller->on_woo_install_or_update();
+		} else {
+			$data_retention_controller->clear_daily_task();
+		}
+	}
+
+	/**
+	 * Wire up the services, unless the feature is disabled.
+	 *
+	 * Runs on `init` priority 1: after the textdomain is loaded, and before
+	 * `WC_Install::check_version()` (priority 5) fires `woocommerce_installed`.
 	 *
 	 * @internal
 	 */
-	public function init_hooks() {
+	public function maybe_init_services(): void {
+		if ( ! FeaturesUtil::feature_is_enabled( self::FEATURE_NAME ) ) {
+			return;
+		}
+
 		add_filter( 'woocommerce_data_stores', array( $this, 'register_data_stores' ) );

 		$container = wc_get_container();
@@ -64,12 +128,20 @@ class StockNotifications {
 	}

 	/**
-	 * Register the data stores.
+	 * Register the data stores, unless the feature is disabled.
 	 *
 	 * @param array $data_stores Data stores.
 	 * @return array
 	 */
 	public function register_data_stores( $data_stores ) {
+		// WC_Data_Store::__construct() re-applies this filter on every data store load,
+		// so re-check the option: the feature can be switched off after this callback was
+		// attached at `init`. Read the option directly rather than through
+		// feature_is_enabled(), which builds translated feature definitions.
+		if ( 'yes' !== get_option( self::ENABLE_OPTION_NAME, 'no' ) ) {
+			return $data_stores;
+		}
+
 		if ( ! is_array( $data_stores ) ) {
 			return $data_stores;
 		}
diff --git a/plugins/woocommerce/tests/legacy/bootstrap.php b/plugins/woocommerce/tests/legacy/bootstrap.php
index faa162bfbff..83113959cd7 100644
--- a/plugins/woocommerce/tests/legacy/bootstrap.php
+++ b/plugins/woocommerce/tests/legacy/bootstrap.php
@@ -260,12 +260,6 @@ class WC_Unit_Tests_Bootstrap {
 		define( 'WC_TAX_ROUNDING_MODE', 'auto' );
 		define( 'WC_USE_TRANSACTIONS', false );

-		// Default Back In Stock alpha to enabled during tests when no
-		// per-suite override has been set.
-		if ( ! defined( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' ) ) {
-			define( 'WOOCOMMERCE_BIS_ALPHA_ENABLED', true );
-		}
-
 		update_option( 'woocommerce_enable_coupons', 'yes' );
 		update_option( 'woocommerce_calc_taxes', 'yes' );
 		update_option( 'woocommerce_onboarding_opt_in', 'yes' );
@@ -300,6 +294,11 @@ class WC_Unit_Tests_Bootstrap {
 		// set in time for ProductCacheController::on_init() to register its invalidation hooks.
 		update_option( 'woocommerce_feature_product_instance_caching_enabled', 'yes' );

+		// Enable Back In Stock Notifications during tests. Set here rather than in load_wc()
+		// because install_wc() includes uninstall.php, which deletes every 'woocommerce_%'
+		// option. This still runs on `setup_theme`, in time for the `init` feature check.
+		update_option( 'woocommerce_feature_customer_stock_notifications_enabled', 'yes' );
+
 		// Reload capabilities after install, see https://core.trac.wordpress.org/ticket/28374.
 		if ( version_compare( $GLOBALS['wp_version'], '4.7', '<' ) ) {
 			$GLOBALS['wp_roles']->reinit();
diff --git a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
index 70455142608..f70b26bc70f 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -5,8 +5,10 @@
  * @package WooCommerce\Tests\Functions.
  */

+use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Blocks\Options as BlockOptions;
 use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
 use Automattic\WooCommerce\Internal\VariationGallery\Package as VariationGalleryPackage;

 /**
@@ -14,6 +16,15 @@ use Automattic\WooCommerce\Internal\VariationGallery\Package as VariationGallery
  */
 class WC_Update_Functions_Test extends \WC_Unit_Test_Case {

+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		Constants::clear_single_constant( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' );
+		delete_option( 'woocommerce_feature_customer_stock_notifications_enabled' );
+		parent::tearDown();
+	}
+
 	/**
 	 * Test wc_update_343_cleanup_foreign_keys() function.
 	 */
@@ -379,4 +390,82 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
 		wc_update_1110_delete_dashboard_outofstock_count_transient();
 		$this->assertFalse( get_transient( 'wc_outofstock_count' ) );
 	}
+
+	/**
+	 * @testdox Migration enables the customer_stock_notifications feature when the alpha constant is set.
+	 */
+	public function test_wc_update_1120_migrate_stock_notifications_alpha_constant_opts_in(): void {
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		$db_updates = WC_Install::get_db_update_callbacks();
+		$this->assertArrayHasKey( '11.2.0', $db_updates );
+		$this->assertContains( 'wc_update_1120_migrate_stock_notifications_alpha_constant', $db_updates['11.2.0'] );
+
+		delete_option( 'woocommerce_feature_customer_stock_notifications_enabled' );
+		Constants::set_constant( 'WOOCOMMERCE_BIS_ALPHA_ENABLED', true );
+
+		wc_update_1120_migrate_stock_notifications_alpha_constant();
+
+		$this->assertSame( 'yes', get_option( 'woocommerce_feature_customer_stock_notifications_enabled' ) );
+	}
+
+	/**
+	 * @testdox Migration leaves the feature untouched when the alpha constant is absent or falsy.
+	 */
+	public function test_wc_update_1120_migrate_stock_notifications_alpha_constant_without_opt_in(): void {
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		delete_option( 'woocommerce_feature_customer_stock_notifications_enabled' );
+		Constants::clear_single_constant( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' );
+
+		wc_update_1120_migrate_stock_notifications_alpha_constant();
+
+		$this->assertFalse( get_option( 'woocommerce_feature_customer_stock_notifications_enabled' ) );
+
+		Constants::set_constant( 'WOOCOMMERCE_BIS_ALPHA_ENABLED', false );
+
+		wc_update_1120_migrate_stock_notifications_alpha_constant();
+
+		$this->assertFalse( get_option( 'woocommerce_feature_customer_stock_notifications_enabled' ) );
+	}
+
+	/**
+	 * @testdox Migration overwrites the 'no' that WC_Install::create_options() seeds before the update callbacks run.
+	 */
+	public function test_wc_update_1120_migrate_stock_notifications_alpha_constant_overwrites_seeded_option(): void {
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		update_option( 'woocommerce_feature_customer_stock_notifications_enabled', 'no' );
+		Constants::set_constant( 'WOOCOMMERCE_BIS_ALPHA_ENABLED', true );
+
+		wc_update_1120_migrate_stock_notifications_alpha_constant();
+
+		$this->assertSame( 'yes', get_option( 'woocommerce_feature_customer_stock_notifications_enabled' ) );
+	}
+
+	/**
+	 * @testdox Migration lets FeaturesController announce the change, so the feature runs its own activation side effects.
+	 */
+	public function test_wc_update_1120_migrate_stock_notifications_alpha_constant_fires_feature_enabled_changed(): void {
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		update_option( 'woocommerce_feature_customer_stock_notifications_enabled', 'no' );
+		Constants::set_constant( 'WOOCOMMERCE_BIS_ALPHA_ENABLED', true );
+
+		$changes  = array();
+		$listener = function ( $feature_id, $enabled ) use ( &$changes ) {
+			$changes[ $feature_id ] = $enabled;
+		};
+
+		add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, $listener, 10, 2 );
+
+		try {
+			wc_update_1120_migrate_stock_notifications_alpha_constant();
+		} finally {
+			remove_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, $listener, 10 );
+		}
+
+		$this->assertArrayHasKey( 'customer_stock_notifications', $changes );
+		$this->assertTrue( $changes['customer_stock_notifications'] );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/StockNotificationsTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/StockNotificationsTests.php
new file mode 100644
index 00000000000..b4793cfcba5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/StockNotificationsTests.php
@@ -0,0 +1,89 @@
+<?php
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\Tests\Internal\StockNotifications;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\StockNotifications\DataRetentionController;
+use Automattic\WooCommerce\Internal\StockNotifications\StockNotifications;
+
+/**
+ * StockNotifications controller tests.
+ */
+class StockNotificationsTests extends \WC_Unit_Test_Case {
+
+	/**
+	 * Clean up after tests.
+	 */
+	public function tearDown(): void {
+		wc_get_container()->get( DataRetentionController::class )->clear_daily_task();
+		delete_option( 'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold' );
+		delete_option( 'woocommerce_queue_flush_rewrite_rules' );
+		parent::tearDown();
+	}
+
+	/**
+	 * Fire the feature-changed action the way FeaturesController does.
+	 *
+	 * @param bool   $enabled    Whether the feature is now enabled.
+	 * @param string $feature_id The feature that changed.
+	 */
+	private function fire_feature_changed( bool $enabled, string $feature_id = StockNotifications::FEATURE_NAME ): void {
+		do_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, $feature_id, $enabled );
+	}
+
+	/**
+	 * @testdox Enabling the feature schedules the daily data retention task.
+	 */
+	public function test_enabling_the_feature_schedules_the_daily_task(): void {
+		update_option( 'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold', 30 );
+		wc_get_container()->get( DataRetentionController::class )->clear_daily_task();
+		$this->assertFalse( wp_get_schedule( DataRetentionController::DAILY_TASK_HOOK ) );
+
+		$this->fire_feature_changed( true );
+
+		$this->assertSame( 'daily', wp_get_schedule( DataRetentionController::DAILY_TASK_HOOK ) );
+	}
+
+	/**
+	 * @testdox Disabling the feature clears the daily data retention task.
+	 */
+	public function test_disabling_the_feature_clears_the_daily_task(): void {
+		update_option( 'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold', 30 );
+		$this->assertSame( 'daily', wp_get_schedule( DataRetentionController::DAILY_TASK_HOOK ) );
+
+		$this->fire_feature_changed( false );
+
+		$this->assertFalse( wp_get_schedule( DataRetentionController::DAILY_TASK_HOOK ) );
+	}
+
+	/**
+	 * @testdox Toggling the feature queues a rewrite rules flush for the My Account endpoint.
+	 */
+	public function test_toggling_the_feature_queues_a_rewrite_flush(): void {
+		foreach ( array( true, false ) as $enabled ) {
+			delete_option( 'woocommerce_queue_flush_rewrite_rules' );
+
+			$this->fire_feature_changed( $enabled );
+
+			$this->assertSame(
+				'yes',
+				get_option( 'woocommerce_queue_flush_rewrite_rules' ),
+				'Toggling the feature should queue a rewrite rules flush.'
+			);
+		}
+	}
+
+	/**
+	 * @testdox Changes to unrelated features are ignored.
+	 */
+	public function test_other_features_are_ignored(): void {
+		update_option( 'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold', 30 );
+		delete_option( 'woocommerce_queue_flush_rewrite_rules' );
+
+		$this->fire_feature_changed( false, 'some_other_feature' );
+
+		$this->assertSame( 'daily', wp_get_schedule( DataRetentionController::DAILY_TASK_HOOK ) );
+		$this->assertFalse( get_option( 'woocommerce_queue_flush_rewrite_rules' ) );
+	}
+}