Commit 8c04074d3f0 for woocommerce

commit 8c04074d3f0e16828127633af2a8eb0fc3f642f6
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date:   Thu Oct 29 11:18:32 2026 +0200

    Add a personal data exporter for back-in-stock notification sign-ups (#69871)

    * fix: export back-in-stock sign-ups with a customer's personal data

    * docs: add @since to new stock notification exporter methods

    * fix: do not repeat variation attributes in exported sign-up product name

    * fix: harden stock notification privacy export against empty emails and deleted products

    * refactor: query stock notification sign-ups by customer in one paged query

    Add a `customer` argument to the stock notifications data store query that
    matches sign-ups by any of the given user IDs or emails, like the `customer`
    argument of `wc_get_orders()`. The privacy exporter now fetches one page per
    request with `limit`/`offset` instead of loading every matching ID and
    slicing in PHP, and the eraser uses the same single query.

    * refactor: trim comments in stock notification privacy exporter

    * fix: only export or erase sign-ups that belong to the requested customer

    * fix: only treat clean positive integers as user IDs in stock notification customer queries

    `absint()` mapped values like `'-5'`, `'1.9'` and `'1e2'` to real user IDs. Those now fall through to the email branch and match nothing.

    Also use `wc_date_format()` / `wc_time_format()` for exported sign-up dates, assert the exact sign-up date, and cover the second page reporting done with a 20-row export.

    * chore: bump @since tags to 11.4.0

diff --git a/plugins/woocommerce/changelog/fix-wooplug-7912-bis-privacy-exporter b/plugins/woocommerce/changelog/fix-wooplug-7912-bis-privacy-exporter
new file mode 100644
index 00000000000..a24d21142e7
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-7912-bis-privacy-exporter
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Include back-in-stock notification sign-ups when exporting a customer's personal data.
diff --git a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
index f9f1928ba01..da9d24b99a3 100644
--- a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
@@ -415,6 +415,7 @@ CREATE TABLE $meta_table_name (
 				'product_id'         => array(),
 				'user_id'            => 0,
 				'user_email'         => '',
+				'customer'           => array(),
 				'last_attempt_limit' => 0,
 				'start_date'         => 0,
 				'end_date'           => 0,
@@ -466,6 +467,32 @@ CREATE TABLE $meta_table_name (
 			$where_values[] = EmailNormalizer::normalize( (string) $args['user_email'] );
 		}

+		if ( ! empty( $args['customer'] ) ) {
+			$customer_ids    = array();
+			$customer_emails = array();
+			foreach ( (array) $args['customer'] as $customer ) {
+				if ( is_int( $customer ) || ( is_string( $customer ) && ctype_digit( $customer ) ) ) {
+					$customer_ids[] = (int) $customer;
+				} elseif ( is_string( $customer ) && '' !== EmailNormalizer::normalize( $customer ) ) {
+					$customer_emails[] = EmailNormalizer::normalize( $customer );
+				}
+			}
+
+			$customer_ids    = array_values( array_unique( array_filter( $customer_ids ) ) );
+			$customer_emails = array_values( array_unique( $customer_emails ) );
+			$customer_where  = array();
+			if ( ! empty( $customer_ids ) ) {
+				$customer_where[] = 'user_id IN (' . implode( ',', array_fill( 0, count( $customer_ids ), '%d' ) ) . ')';
+				$where_values     = array_merge( $where_values, $customer_ids );
+			}
+			if ( ! empty( $customer_emails ) ) {
+				$customer_where[] = 'user_email IN (' . implode( ',', array_fill( 0, count( $customer_emails ), '%s' ) ) . ')';
+				$where_values     = array_merge( $where_values, $customer_emails );
+			}
+
+			$where[] = empty( $customer_where ) ? '1=0' : '(' . implode( ' OR ', $customer_where ) . ')';
+		}
+
 		if ( $args['last_attempt_limit'] > 0 ) {
 			$where[]        = '(date_last_attempt_gmt < %s OR date_last_attempt_gmt IS NULL)';
 			$where_values[] = gmdate( 'Y-m-d H:i:s', $args['last_attempt_limit'] );
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php b/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php
index 2489ef7b61a..1e21205e3cc 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php
@@ -6,19 +6,25 @@ namespace Automattic\WooCommerce\Internal\StockNotifications\Privacy;

 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource;
 use Automattic\WooCommerce\Internal\StockNotifications\Factory;
+use Automattic\WooCommerce\Internal\StockNotifications\Frontend\MyAccountEndpoint;
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Internal\StockNotifications\NotificationQuery;
 use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EmailNormalizer;

 /**
- * Privacy eraser for WooCommerce Customer Stock Notifications.
+ * Privacy exporter and eraser for WooCommerce Customer Stock Notifications.
  *
- * This class handles the erasure of customer stock notification data for users
- * who request their personal data to be erased.
+ * This class handles the export and erasure of customer stock notification data
+ * for users who request their personal data.
  */
 class PrivacyEraser extends \WC_Abstract_Privacy {

+	/**
+	 * Number of sign-ups exported per page.
+	 */
+	private const EXPORT_PAGE_SIZE = 10;
+
 	/**
 	 * Constructor.
 	 */
@@ -29,9 +35,14 @@ class PrivacyEraser extends \WC_Abstract_Privacy {
 	}

 	/**
-	 * Register the eraser for stock notifications.
+	 * Register the exporter and eraser for stock notifications.
 	 */
 	public function register_erasers_exporters() {
+		$this->add_exporter(
+			'woocommerce-customer-stock-notifications',
+			__( 'WooCommerce Customer Stock Notifications', 'woocommerce' ),
+			array( $this, 'export_notification_data' )
+		);
 		$this->add_eraser(
 			'woocommerce-customer-stock-notifications',
 			__( 'WooCommerce Customer Stock Notifications', 'woocommerce' ),
@@ -39,6 +50,161 @@ class PrivacyEraser extends \WC_Abstract_Privacy {
 		);
 	}

+	/**
+	 * Export customer stock notification data for a given email address and the account it belongs to.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string $email_address The email address to export data for.
+	 * @param int    $page          Page number, starting at 1.
+	 *
+	 * @return array Response containing the exported data and whether the export is done.
+	 */
+	public static function export_notification_data( $email_address, $page = 1 ): array {
+		$customer       = self::get_customer( is_string( $email_address ) ? $email_address : '' );
+		$page           = max( 1, (int) $page );
+		$data_to_export = array();
+		$done           = true;
+
+		if ( ! empty( $customer ) ) {
+			$notification_ids = NotificationQuery::get_notifications(
+				array(
+					'customer' => $customer,
+					'limit'    => self::EXPORT_PAGE_SIZE,
+					'offset'   => ( $page - 1 ) * self::EXPORT_PAGE_SIZE,
+				)
+			);
+
+			foreach ( $notification_ids as $notification_id ) {
+				$notification = Factory::get_notification( (int) $notification_id );
+				if ( ! $notification instanceof Notification || ! self::belongs_to_customer( $notification, $customer ) ) {
+					continue;
+				}
+
+				$data_to_export[] = array(
+					'group_id'          => 'woocommerce_stock_notifications',
+					'group_label'       => __( 'Stock Notifications', 'woocommerce' ),
+					'group_description' => __( 'User&#8217;s WooCommerce back-in-stock notification sign-ups.', 'woocommerce' ),
+					'item_id'           => 'stock-notification-' . $notification->get_id(),
+					'data'              => self::get_notification_personal_data( $notification ),
+				);
+			}
+
+			$done = count( $notification_ids ) < self::EXPORT_PAGE_SIZE
+				|| NotificationQuery::count_notifications( array( 'customer' => $customer ) ) <= $page * self::EXPORT_PAGE_SIZE;
+		}
+
+		return array(
+			'data' => $data_to_export,
+			'done' => $done,
+		);
+	}
+
+	/**
+	 * Get the personal data to export for a single sign-up.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param Notification $notification The sign-up.
+	 *
+	 * @return array List of name/value pairs.
+	 */
+	private static function get_notification_personal_data( Notification $notification ): array {
+		$product_name = MyAccountEndpoint::get_display_product_name( $notification );
+		if ( '' === $product_name ) {
+			/* translators: %d: product ID */
+			$product_name = sprintf( __( 'Product #%d', 'woocommerce' ), $notification->get_product_id() );
+		}
+		$variation = $notification->get_product_formatted_variation_list( true );
+		if ( '' !== $variation ) {
+			$product_name .= ' ' . $variation;
+		}
+
+		$status_labels = array(
+			NotificationStatus::PENDING   => _x( 'Pending', 'stock notification status', 'woocommerce' ),
+			NotificationStatus::ACTIVE    => _x( 'Active', 'stock notification status', 'woocommerce' ),
+			NotificationStatus::SENT      => _x( 'Sent', 'stock notification status', 'woocommerce' ),
+			NotificationStatus::CANCELLED => _x( 'Cancelled', 'stock notification status', 'woocommerce' ),
+		);
+		$status        = (string) $notification->get_status();
+		$date_format   = wc_date_format() . ', ' . wc_time_format();
+		$date_created  = $notification->get_date_created();
+		$date_notified = $notification->get_date_notified();
+
+		return array(
+			array(
+				'name'  => __( 'Product', 'woocommerce' ),
+				'value' => $product_name,
+			),
+			array(
+				'name'  => __( 'Product URL', 'woocommerce' ),
+				'value' => $notification->get_product_permalink(),
+			),
+			array(
+				'name'  => __( 'Email', 'woocommerce' ),
+				'value' => $notification->get_user_email(),
+			),
+			array(
+				'name'  => __( 'Status', 'woocommerce' ),
+				'value' => $status_labels[ $status ] ?? $status,
+			),
+			array(
+				'name'  => __( 'Date signed up', 'woocommerce' ),
+				'value' => $date_created ? wc_format_datetime( $date_created, $date_format ) : '',
+			),
+			array(
+				'name'  => __( 'Date notified', 'woocommerce' ),
+				'value' => $date_notified ? wc_format_datetime( $date_notified, $date_format ) : '',
+			),
+		);
+	}
+
+	/**
+	 * Get the `customer` query values for an email address and the account it belongs to.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string $email_address The email address, in the letter case the request was made with.
+	 *
+	 * @return array<int|string> Normalized email plus user ID when there is an account. Empty for an invalid email.
+	 */
+	private static function get_customer( string $email_address ): array {
+		$normalized_email = EmailNormalizer::normalize( $email_address );
+		if ( '' === $normalized_email || ! is_email( $normalized_email ) ) {
+			return array();
+		}
+
+		$customer = array( $normalized_email );
+
+		// Look up the account with the letter case as entered: `wp_users.user_email` is never
+		// normalized, so on a case-sensitive collation the lowercased form would miss it.
+		$user = get_user_by( 'email', $email_address );
+		if ( $user instanceof \WP_User ) {
+			$customer[] = (int) $user->ID;
+		}
+
+		return $customer;
+	}
+
+	/**
+	 * Check that a sign-up belongs to the customer being exported or erased.
+	 *
+	 * The data store can be replaced through the `woocommerce_stock_notification_data_store`
+	 * filter, and a replacement that ignores the `customer` query argument would return every
+	 * sign-up. Checking each row keeps both paths from touching other customers' data.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param Notification      $notification The sign-up.
+	 * @param array<int|string> $customer     The `customer` query values from `get_customer()`.
+	 *
+	 * @return bool
+	 */
+	private static function belongs_to_customer( Notification $notification, array $customer ): bool {
+		return in_array( (int) $notification->get_user_id(), $customer, true )
+			|| in_array( EmailNormalizer::normalize( (string) $notification->get_user_email() ), $customer, true );
+	}
+
 	/**
 	 * Erase customer stock notification data for a given email address and the account it belongs to.
 	 *
@@ -57,34 +223,13 @@ class PrivacyEraser extends \WC_Abstract_Privacy {
 			'done'           => true,
 		);

-		// Look up the account with the letter case as entered: `wp_users.user_email` is never
-		// normalized, so on a case-sensitive collation the lowercased form would miss it.
-		$user          = get_user_by( 'email', $email_address );
+		$customer      = self::get_customer( $email_address );
 		$email_address = EmailNormalizer::normalize( $email_address );
-
-		$notifications = NotificationQuery::get_notifications(
-			array(
-				'user_email' => $email_address,
-			)
-		);
-
-		// Sign-ups made before an account email change keep the old email, so also match by user ID.
-		if ( $user instanceof \WP_User ) {
-			$notifications = array_unique(
-				array_merge(
-					$notifications,
-					NotificationQuery::get_notifications(
-						array(
-							'user_id' => $user->ID,
-						)
-					)
-				)
-			);
-		}
+		$notifications = empty( $customer ) ? array() : NotificationQuery::get_notifications( array( 'customer' => $customer ) );

 		foreach ( $notifications as $notification_id ) {
-			$notification = Factory::get_notification( $notification_id );
-			if ( ! $notification instanceof Notification ) {
+			$notification = Factory::get_notification( (int) $notification_id );
+			if ( ! $notification instanceof Notification || ! self::belongs_to_customer( $notification, $customer ) ) {
 				continue;
 			}

diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
index 3c9ad028fc7..a2d4902de50 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
@@ -466,6 +466,51 @@ class StockNotificationsDataStoreTests extends \WC_Unit_Test_Case {
 		$this->assertCount( 1, $notifications );
 	}

+	/**
+	 * @testdox Should match sign-ups by any of the given user IDs or emails with the `customer` argument.
+	 */
+	public function test_query_notifications_with_customer() {
+
+		$by_id = new Notification();
+		$by_id->set_product_id( 1 );
+		$by_id->set_user_id( 1 );
+		$by_id->set_user_email( 'old@test.com' );
+		$by_id_id = $by_id->save();
+
+		$by_email = new Notification();
+		$by_email->set_product_id( 1 );
+		$by_email->set_user_email( 'new@test.com' );
+		$by_email_id = $by_email->save();
+
+		$other = new Notification();
+		$other->set_product_id( 1 );
+		$other->set_user_id( 2 );
+		$other->set_user_email( 'other@test.com' );
+		$other->save();
+
+		$notifications = $this->data_store->query(
+			array(
+				'customer' => array( 'New@Test.com', 1 ),
+			)
+		);
+		$this->assertSame( array( $by_id_id, $by_email_id ), $notifications );
+
+		$this->assertSame( array( $by_email_id ), $this->data_store->query( array( 'customer' => array( 'new@test.com' ) ) ) );
+		$this->assertSame( array( $by_id_id ), $this->data_store->query( array( 'customer' => array( 1 ) ) ) );
+		$this->assertSame(
+			2,
+			$this->data_store->query(
+				array(
+					'customer' => array( 'new@test.com', 1 ),
+					'return'   => 'count',
+				)
+			)
+		);
+		$this->assertSame( array(), $this->data_store->query( array( 'customer' => array( '', 0 ) ) ) );
+		$this->assertSame( array( $by_id_id ), $this->data_store->query( array( 'customer' => array( '1' ) ) ) );
+		$this->assertSame( array(), $this->data_store->query( array( 'customer' => array( '-1', '1.9', '1e0' ) ) ) );
+	}
+
 	/**
 	 * Test querying notifications with a last attempt limit.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php
index 2664324fb70..26d7dcc19ce 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php
@@ -3,7 +3,10 @@
 declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Privacy;

+use Automattic\WooCommerce\Internal\DataStores\StockNotifications\StockNotificationsDataStore;
+use Automattic\WooCommerce\Internal\DataStores\StockNotifications\StockNotificationsMetaDataStore;
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource;
+use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
 use Automattic\WooCommerce\Internal\StockNotifications\Privacy\PrivacyEraser;
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
@@ -169,4 +172,247 @@ class PrivacyEraserTests extends \WC_Unit_Test_Case {
 		$this->assertSame( $other, $untouched_notification->get_user_id() );
 		$this->assertSame( 'other@doe.com', $untouched_notification->get_user_email() );
 	}
+
+	/**
+	 * @testdox Should export a user's sign-ups under their previous and current email once each, plus guest rows for the current email, and leave other users out.
+	 */
+	public function test_privacy_exporter_matches_email_and_user_id(): void {
+		$user_id = self::factory()->user->create( array( 'user_email' => 'old@doe.com' ) );
+		$other   = self::factory()->user->create( array( 'user_email' => 'other@doe.com' ) );
+		$product = \WC_Helper_Product::create_simple_product();
+
+		$user_notification = new Notification();
+		$user_notification->set_user_id( $user_id );
+		$user_notification->set_user_email( 'old@doe.com' );
+		$user_notification->set_product_id( $product->get_id() );
+		$user_notification->set_date_created( 1690000000 );
+		$user_notification_id = $user_notification->save();
+
+		$guest_notification = new Notification();
+		$guest_notification->set_user_email( 'new@doe.com' );
+		$guest_notification->set_product_id( $product->get_id() );
+		$guest_notification_id = $guest_notification->save();
+
+		$other_notification = new Notification();
+		$other_notification->set_user_id( $other );
+		$other_notification->set_user_email( 'other@doe.com' );
+		$other_notification->set_product_id( $product->get_id() );
+		$other_notification->save();
+
+		wp_update_user(
+			array(
+				'ID'         => $user_id,
+				'user_email' => 'new@doe.com',
+			)
+		);
+
+		$current_notification = new Notification();
+		$current_notification->set_user_id( $user_id );
+		$current_notification->set_user_email( 'new@doe.com' );
+		$current_notification->set_product_id( $product->get_id() );
+		$current_notification->set_status( NotificationStatus::SENT );
+		$current_notification->set_date_created( 1700000000 );
+		$current_notification->set_date_notified( 1700003600 );
+		$current_notification_id = $current_notification->save();
+
+		$response = PrivacyEraser::export_notification_data( 'New@Doe.COM' );
+
+		$this->assertTrue( $response['done'] );
+		$this->assertSame(
+			array(
+				'stock-notification-' . $user_notification_id,
+				'stock-notification-' . $guest_notification_id,
+				'stock-notification-' . $current_notification_id,
+			),
+			array_column( $response['data'], 'item_id' )
+		);
+
+		$item = $response['data'][0];
+		$this->assertSame( 'woocommerce_stock_notifications', $item['group_id'] );
+
+		$values = array_column( $item['data'], 'value', 'name' );
+		$this->assertSame( $product->get_name(), $values['Product'] );
+		$this->assertSame( $product->get_permalink(), $values['Product URL'] );
+		$this->assertSame( 'old@doe.com', $values['Email'] );
+		$this->assertSame( 'Pending', $values['Status'] );
+		$date_format = wc_date_format() . ', ' . wc_time_format();
+		$this->assertSame( wc_format_datetime( new \WC_DateTime( '@1690000000' ), $date_format ), $values['Date signed up'] );
+		$this->assertSame( '', $values['Date notified'] );
+
+		$sent_values = array_column( $response['data'][2]['data'], 'value', 'name' );
+		$this->assertSame( 'Sent', $sent_values['Status'] );
+		$this->assertSame( wc_format_datetime( new \WC_DateTime( '@1700000000' ), $date_format ), $sent_values['Date signed up'] );
+		$this->assertSame( wc_format_datetime( new \WC_DateTime( '@1700003600' ), $date_format ), $sent_values['Date notified'] );
+	}
+
+	/**
+	 * @testdox Should export nothing for an empty or invalid email instead of every sign-up.
+	 */
+	public function test_privacy_exporter_ignores_empty_and_invalid_email(): void {
+		$notification = new Notification();
+		$notification->set_user_email( 'jon@doe.com' );
+		$notification->set_product_id( 1 );
+		$notification->save();
+
+		foreach ( array( '', '   ', '0', 'not-an-email' ) as $email ) {
+			$response = PrivacyEraser::export_notification_data( $email );
+			$this->assertSame( array(), $response['data'], "Email '{$email}' should export nothing." );
+			$this->assertTrue( $response['done'] );
+		}
+
+		$erased = PrivacyEraser::erase_notification_data( '' );
+		$this->assertFalse( $erased['items_removed'] );
+		$this->assertSame( 'jon@doe.com', ( new Notification( $notification->get_id() ) )->get_user_email() );
+	}
+
+	/**
+	 * @testdox Should export the product ID when the product has been deleted.
+	 */
+	public function test_privacy_exporter_falls_back_to_product_id(): void {
+		$notification = new Notification();
+		$notification->set_user_email( 'jon@doe.com' );
+		$notification->set_product_id( 987654 );
+		$notification->save();
+
+		$response = PrivacyEraser::export_notification_data( 'jon@doe.com' );
+		$values   = array_column( $response['data'][0]['data'], 'value', 'name' );
+
+		$this->assertSame( 'Product #987654', $values['Product'] );
+		$this->assertSame( '', $values['Product URL'] );
+	}
+
+	/**
+	 * @testdox Should register the exporter alongside the eraser.
+	 */
+	public function test_privacy_exporter_is_registered(): void {
+		$privacy = new PrivacyEraser();
+		$privacy->register_erasers_exporters();
+
+		$exporters = apply_filters( 'wp_privacy_personal_data_exporters', array() );
+
+		$this->assertArrayHasKey( 'woocommerce-customer-stock-notifications', $exporters );
+		$callback = $exporters['woocommerce-customer-stock-notifications']['callback'];
+		$this->assertIsCallable( $callback );
+		$this->assertSame( array( $privacy, 'export_notification_data' ), $callback );
+	}
+
+	/**
+	 * @testdox Should export a variation as its parent title followed by the attributes once.
+	 */
+	public function test_privacy_exporter_formats_variation_name(): void {
+		$product   = \WC_Helper_Product::create_variation_product();
+		$variation = wc_get_product( $product->get_children()[0] );
+
+		$notification = new Notification();
+		$notification->set_user_email( 'jon@doe.com' );
+		$notification->set_product_id( $variation->get_id() );
+		$notification->save();
+
+		$response = PrivacyEraser::export_notification_data( 'jon@doe.com' );
+		$values   = array_column( $response['data'][0]['data'], 'value', 'name' );
+
+		$this->assertSame( $product->get_name() . ' size: small', $values['Product'] );
+		$this->assertSame( $variation->get_permalink(), $values['Product URL'] );
+	}
+
+	/**
+	 * @testdox Should export sign-ups ten per page and report done on the last page.
+	 */
+	public function test_privacy_exporter_paginates(): void {
+		for ( $i = 1; $i <= 10; $i++ ) {
+			$notification = new Notification();
+			$notification->set_user_email( 'jon@doe.com' );
+			$notification->set_product_id( $i );
+			$notification->save();
+		}
+
+		$full_page = PrivacyEraser::export_notification_data( 'jon@doe.com', 1 );
+		$this->assertCount( 10, $full_page['data'] );
+		$this->assertTrue( $full_page['done'] );
+
+		$notification = new Notification();
+		$notification->set_user_email( 'jon@doe.com' );
+		$notification->set_product_id( 11 );
+		$notification->save();
+
+		$first_page = PrivacyEraser::export_notification_data( 'jon@doe.com', 1 );
+		$this->assertCount( 10, $first_page['data'] );
+		$this->assertFalse( $first_page['done'] );
+
+		$second_page = PrivacyEraser::export_notification_data( 'jon@doe.com', 2 );
+		$this->assertCount( 1, $second_page['data'] );
+		$this->assertTrue( $second_page['done'] );
+
+		$this->assertEmpty( array_intersect( array_column( $first_page['data'], 'item_id' ), array_column( $second_page['data'], 'item_id' ) ) );
+
+		$notification_ids = array( (int) $notification->get_id() );
+		for ( $i = 12; $i <= 20; $i++ ) {
+			$notification = new Notification();
+			$notification->set_user_email( 'jon@doe.com' );
+			$notification->set_product_id( $i );
+			$notification_ids[] = $notification->save();
+		}
+
+		$first_page = PrivacyEraser::export_notification_data( 'jon@doe.com', 1 );
+		$this->assertCount( 10, $first_page['data'] );
+		$this->assertFalse( $first_page['done'] );
+
+		$second_page = PrivacyEraser::export_notification_data( 'jon@doe.com', 2 );
+		$this->assertTrue( $second_page['done'] );
+		$this->assertSame(
+			array_map(
+				static fn( $id ) => 'stock-notification-' . $id,
+				$notification_ids
+			),
+			array_column( $second_page['data'], 'item_id' )
+		);
+	}
+
+	/**
+	 * @testdox Should report done with no data when the email has no sign-ups.
+	 */
+	public function test_privacy_exporter_returns_done_when_empty(): void {
+		$response = PrivacyEraser::export_notification_data( 'nobody@doe.com' );
+
+		$this->assertSame( array(), $response['data'] );
+		$this->assertTrue( $response['done'] );
+	}
+
+	/**
+	 * @testdox Should skip sign-ups of other customers when the data store ignores the `customer` query argument.
+	 */
+	public function test_privacy_export_and_erase_skip_other_customers_rows(): void {
+		$mine = new Notification();
+		$mine->set_user_email( 'jon@doe.com' );
+		$mine->set_product_id( 1 );
+		$mine_id = $mine->save();
+
+		$other = new Notification();
+		$other->set_user_email( 'other@doe.com' );
+		$other->set_product_id( 1 );
+		$other_id = $other->save();
+
+		$store = new class() extends StockNotificationsDataStore {
+			/**
+			 * Query like a store written before the `customer` argument existed.
+			 *
+			 * @param array $args The arguments.
+			 * @return array<int>|array<Notification>|int
+			 */
+			public function query( array $args ) {
+				unset( $args['customer'] );
+				return parent::query( $args );
+			}
+		};
+		$store->init( wc_get_container()->get( StockNotificationsMetaDataStore::class ), wc_get_container()->get( DatabaseUtil::class ) );
+		add_filter( 'woocommerce_stock_notification_data_store', fn() => $store );
+
+		$response = PrivacyEraser::export_notification_data( 'jon@doe.com' );
+		$this->assertSame( array( 'stock-notification-' . $mine_id ), array_column( $response['data'], 'item_id' ) );
+
+		$erased = PrivacyEraser::erase_notification_data( 'jon@doe.com' );
+		$this->assertTrue( $erased['items_removed'] );
+		$this->assertSame( NotificationStatus::CANCELLED, ( new Notification( $mine_id ) )->get_status() );
+		$this->assertSame( 'other@doe.com', ( new Notification( $other_id ) )->get_user_email() );
+	}
 }