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’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() );
+ }
}