Commit 6eb352276b6 for woocommerce
commit 6eb352276b68681456559f4e01a1ad56c023c47e
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date: Tue Sep 22 17:47:29 2026 +0300
Make the Back in Stock duplicate sign-up lookup a fixed cost (#68915)
* fix: match BIS duplicate sign-ups with one exact query, validate attributes
* fix: keep a fixed BIS variation attribute valued '0' out of the posted set
* fix: sort BIS posted attributes by key so duplicate matching ignores order
* fix: reject non-string BIS attribute values before the string sanitizers
diff --git a/plugins/woocommerce/changelog/fix-bis-bounded-duplicate-signup-lookup b/plugins/woocommerce/changelog/fix-bis-bounded-duplicate-signup-lookup
new file mode 100644
index 00000000000..f7d731e7629
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-bis-bounded-duplicate-signup-lookup
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Validate posted Back in Stock variation attributes against the values the store offers, look up an existing sign-up with a single exact query, and check the sign-up rate limit before it.
diff --git a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
index 33b79561e17..6cc1e782235 100644
--- a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
@@ -605,6 +605,60 @@ CREATE TABLE $meta_table_name (
return (int) $wpdb->get_var( $sql ) > 0; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
+ /**
+ * Get the ID of the active or pending notification matching an identity, product and posted
+ * attribute set.
+ *
+ * The posted attributes are matched as the serialized string they are stored as, so the
+ * comparison is exact and case sensitive, but not sensitive to key order: both sides are
+ * sorted by key. An empty set matches any sign-up for the identity.
+ *
+ * @param int $product_id The product ID.
+ * @param int $user_id The user ID, or 0 to match on the email instead.
+ * @param string $user_email The email address, used when no user ID is given.
+ * @param array $posted_attributes The posted attributes to match.
+ * @return int The notification ID, or 0 when nothing matches.
+ */
+ public function get_matching_notification_id( int $product_id, int $user_id, string $user_email, array $posted_attributes = array() ): int {
+
+ if ( empty( $product_id ) ) {
+ return 0;
+ }
+
+ if ( empty( $user_id ) ) {
+ $user_email = EmailNormalizer::normalize( $user_email );
+ if ( ! is_email( $user_email ) ) {
+ return 0;
+ }
+ }
+
+ global $wpdb;
+
+ $table = $this->get_table_name();
+ $identity_where = empty( $user_id ) ? 'user_email = %s' : 'user_id = %d';
+ $identity_value = empty( $user_id ) ? $user_email : $user_id;
+
+ if ( empty( $posted_attributes ) ) {
+ $sql = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ "SELECT id FROM $table WHERE product_id = %d AND $identity_where AND status IN (%s, %s) LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ array( $product_id, $identity_value, NotificationStatus::ACTIVE, NotificationStatus::PENDING )
+ );
+
+ return absint( $wpdb->get_var( $sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ }
+
+ // Sort by key so the serialized blob is the same whatever order the caller built the set in.
+ ksort( $posted_attributes );
+
+ $meta_table = $this->get_meta_table_name();
+ $sql = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ "SELECT notifications.id FROM $table AS notifications INNER JOIN $meta_table AS meta ON meta.notification_id = notifications.id AND meta.meta_key = %s WHERE notifications.product_id = %d AND notifications.$identity_where AND notifications.status IN (%s, %s) AND BINARY meta.meta_value = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ array( 'posted_attributes', $product_id, $identity_value, NotificationStatus::ACTIVE, NotificationStatus::PENDING, maybe_serialize( $posted_attributes ) )
+ );
+
+ return absint( $wpdb->get_var( $sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ }
+
/**
* Get distinct notification creation dates.
*
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
index 03a7b7a6eb9..134db09c2d4 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
@@ -50,6 +50,11 @@ class SignupService {
public const ERROR_INVALID_OPT_IN = 'invalid_opt_in';
// phpcs:enable
+ /**
+ * Maximum length allowed for a single posted attribute value.
+ */
+ private const MAX_ATTRIBUTE_LENGTH = 255;
+
/**
* Eligibility service.
*
@@ -105,6 +110,9 @@ class SignupService {
* exception raised further down still holds the customer back until the window expires. A
* window that cannot be claimed at all is the exception, and lets the sign-up through.
*
+ * The rate limit is checked before the duplicate sign-up lookup, so a limited request is
+ * rejected without also paying for that lookup.
+ *
* @param int $product_id The product ID.
* @param int $user_id The user ID.
* @param string $user_email The user email.
@@ -141,9 +149,13 @@ class SignupService {
return new \WP_Error( self::ERROR_INVALID_PRODUCT );
}
- // Attempts that only find an existing active or pending sign-up, or activate an existing
- // pending one, create nothing new and send no verification mail, so they are answered
- // before the rate limit is consulted or claimed.
+ if ( SignupRateLimiter::is_rate_limited( $user_email ) ) {
+ return new \WP_Error( self::ERROR_RATE_LIMITED );
+ }
+
+ // An existing rate limit window blocks these attempts too, but attempts that only find
+ // an existing active or pending sign-up, or activate an existing pending one, never claim
+ // a window themselves: they create nothing new and send no verification mail.
$notification = $this->is_already_signed_up( $product_id, $user_id, $user_email, $posted_attributes );
if ( $notification instanceof Notification ) {
if ( NotificationStatus::ACTIVE === $notification->get_status() ) {
@@ -174,10 +186,6 @@ class SignupService {
}
}
- if ( SignupRateLimiter::is_rate_limited( $user_email ) ) {
- return new \WP_Error( self::ERROR_RATE_LIMITED );
- }
-
// Claim the rate limit window before storing a notification or sending mail. This
// narrows the window in which two near-simultaneous requests both get through; it
// does not close it.
@@ -199,6 +207,8 @@ class SignupService {
$notification->set_user_email( $user_email );
if ( ! empty( $posted_attributes ) ) {
+ // Sort by key so the stored blob matches what the duplicate lookup serializes.
+ ksort( $posted_attributes );
$notification->update_meta_data( 'posted_attributes', $posted_attributes );
}
@@ -253,64 +263,31 @@ class SignupService {
// email, or vice versa. Match on user ID first, then fall back to the email so both states are found.
$identities = array();
if ( ! empty( $user_id ) ) {
- $identities[] = array( 'user_id' => $user_id );
+ $identities[] = array(
+ 'user_id' => $user_id,
+ 'user_email' => '',
+ );
}
if ( ! empty( $user_email ) ) {
- $identities[] = array( 'user_email' => $user_email );
+ $identities[] = array(
+ 'user_id' => 0,
+ 'user_email' => $user_email,
+ );
}
foreach ( $identities as $identity ) {
- $notifications = NotificationQuery::get_notifications(
- array_merge(
- $identity,
- array(
- 'product_id' => $product_id,
- 'status' => array( NotificationStatus::ACTIVE, NotificationStatus::PENDING ),
- 'order_by' => array( 'id' => 'DESC' ),
- 'return' => 'objects',
- )
- )
- );
-
- foreach ( $notifications as $notification ) {
- if ( $notification instanceof Notification && $this->matches_posted_attributes( $notification, $posted_attributes ) ) {
- return $notification;
- }
+ $notification_id = NotificationQuery::get_matching_notification_id( $product_id, $identity['user_id'], $identity['user_email'], $posted_attributes );
+ if ( empty( $notification_id ) ) {
+ continue;
}
- }
-
- return null;
- }
-
- /**
- * Check whether a notification was signed up for with the posted attributes.
- *
- * A variation with "any" attributes can be signed up for more than once with different
- * attribute values, so the stored attributes have to match the posted ones. An empty set
- * of posted attributes matches any notification.
- *
- * @param Notification $notification The notification.
- * @param array $posted_attributes The posted attributes.
- * @return bool True if the notification matches the posted attributes.
- */
- private function matches_posted_attributes( Notification $notification, array $posted_attributes ): bool {
-
- if ( empty( $posted_attributes ) ) {
- return true;
- }
-
- $stored_attributes = $notification->get_meta( 'posted_attributes' );
- if ( ! is_array( $stored_attributes ) || count( $stored_attributes ) !== count( $posted_attributes ) ) {
- return false;
- }
- foreach ( $posted_attributes as $key => $value ) {
- if ( ! array_key_exists( $key, $stored_attributes ) || (string) $stored_attributes[ $key ] !== (string) $value ) {
- return false;
+ $notification = Factory::get_notification( $notification_id );
+ if ( $notification instanceof Notification ) {
+ return $notification;
}
}
- return true;
+ return null;
}
/**
@@ -341,6 +318,9 @@ class SignupService {
$parsed_data['product_id'] = $product->get_id();
if ( $product instanceof \WC_Product_Variation ) {
$posted_attributes = $this->parse_posted_attributes( $source, $product );
+ if ( \is_wp_error( $posted_attributes ) ) {
+ return $posted_attributes;
+ }
if ( ! empty( $posted_attributes ) ) {
$parsed_data['posted_attributes'] = $posted_attributes;
@@ -426,13 +406,21 @@ class SignupService {
* For example, if a t-shirt variation has 'any' size but a specific color, we need to capture
* the chosen size from the form submission while the color comes from the variation itself.
*
- * @see \WC_Cart::add_to_cart() for similar attribute parsing logic.
+ * Only 'any' attributes are read from the request. Every attribute the variation fixes is
+ * already identified by the variation ID, so a posted value for one carries no information
+ * and is ignored.
+ *
+ * Posted values are checked against the attribute's declared values, the same way
+ * `WC_Cart::add_to_cart()` checks them for an 'any' attribute, so a request cannot store a
+ * value the store never offered and mint a sign-up row that no later request can match.
+ *
+ * @see \WC_Cart::add_to_cart() for similar attribute parsing and validation logic.
*
* @param array $source The source data, e.g. $_POST or $_REQUEST.
* @param \WC_Product $variation The variation.
- * @return array The posted attributes.
+ * @return array|\WP_Error The posted attributes, or a WP_Error if a posted value is too long or not one the store offers.
*/
- private function parse_posted_attributes( array $source, \WC_Product $variation ): array {
+ private function parse_posted_attributes( array $source, \WC_Product $variation ) {
if ( ! $variation instanceof \WC_Product_Variation ) {
return array();
@@ -443,34 +431,46 @@ class SignupService {
return array();
}
+ // Empty values are the 'any' attributes, so what is left is the set the variation fixes.
+ $fixed_attributes = array_filter( $variation->get_variation_attributes(), 'wc_array_filter_default_attributes' );
+
$posted_attributes = array();
foreach ( $product->get_attributes() as $attribute ) {
- if ( ! $attribute['is_variation'] ) {
+ if ( ! $attribute instanceof \WC_Product_Attribute || ! $attribute['is_variation'] ) {
continue;
}
$attribute_key = 'attribute_' . sanitize_title( $attribute['name'] );
- if ( isset( $source[ $attribute_key ] ) ) {
- if ( $attribute['is_taxonomy'] ) {
- $value = sanitize_title( wp_unslash( $source[ $attribute_key ] ) );
- } else {
- $value = html_entity_decode( wc_clean( wp_unslash( $source[ $attribute_key ] ) ), ENT_QUOTES, get_bloginfo( 'charset' ) );
- }
+ if ( isset( $fixed_attributes[ $attribute_key ] ) || ! isset( $source[ $attribute_key ] ) ) {
+ continue;
+ }
- // Don't include if it's empty.
- if ( ! empty( $value ) || '0' === $value ) {
- $posted_attributes[ $attribute_key ] = $value;
- }
+ // A request can post the value as an array, which the string sanitizers below cannot take.
+ $raw_value = wp_unslash( $source[ $attribute_key ] );
+ if ( ! is_string( $raw_value ) ) {
+ return new \WP_Error( self::ERROR_INVALID_REQUEST );
+ }
+
+ if ( $attribute['is_taxonomy'] ) {
+ $value = sanitize_title( $raw_value );
+ } else {
+ $value = html_entity_decode( wc_clean( $raw_value ), ENT_QUOTES, get_bloginfo( 'charset' ) );
}
- }
- $variation_attributes = $variation->get_variation_attributes();
- // Filter out 'any' variations, which are empty.
- $variation_attributes = array_filter( $variation_attributes );
- $diff = array_diff( $posted_attributes, $variation_attributes );
+ // Don't include if it's empty.
+ if ( empty( $value ) && '0' !== $value ) {
+ continue;
+ }
+
+ // Length first, so an oversized value is rejected without reading the declared ones.
+ if ( strlen( $value ) > self::MAX_ATTRIBUTE_LENGTH || ! in_array( $value, $attribute->get_slugs(), true ) ) {
+ return new \WP_Error( self::ERROR_INVALID_REQUEST );
+ }
+
+ $posted_attributes[ $attribute_key ] = $value;
+ }
- // Return the posted attributes only if a variation with `any` attribute is detected.
- return ! empty( $diff ) ? $diff : array();
+ return $posted_attributes;
}
/**
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/NotificationQuery.php b/plugins/woocommerce/src/Internal/StockNotifications/NotificationQuery.php
index 6309ad134e9..56889e74ad9 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/NotificationQuery.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/NotificationQuery.php
@@ -94,6 +94,23 @@ class NotificationQuery {
return $data_store ? $data_store->notification_exists_by_email( $product_id, $email ) : false;
}
+ /**
+ * Get the ID of the active or pending notification matching an identity, product and posted
+ * attribute set.
+ *
+ * @param int $product_id The product ID.
+ * @param int $user_id The user ID, or 0 to match on the email instead.
+ * @param string $user_email The email address, used when no user ID is given.
+ * @param array $posted_attributes The posted attributes to match.
+ * @return int The notification ID, or 0 when nothing matches.
+ */
+ public static function get_matching_notification_id( int $product_id, int $user_id, string $user_email, array $posted_attributes = array() ): int {
+ $data_store = self::load_data_store();
+
+ // @phpstan-ignore method.notFound (the call is proxied by WC_Data_Store::__call())
+ return $data_store ? absint( $data_store->get_matching_notification_id( $product_id, $user_id, $user_email, $posted_attributes ) ) : 0;
+ }
+
/**
* Get a notification by user ID.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
index 4036810ba8a..9cada6a3654 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
@@ -3,6 +3,7 @@
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Frontend;
+use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
use Automattic\WooCommerce\Internal\StockNotifications\Frontend\NotificationManagementService;
@@ -13,6 +14,9 @@ use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EligibilityServ
use Automattic\WooCommerce\Internal\StockNotifications\Utilities\StockManagementHelper;
use Automattic\WooCommerce\Tests\Internal\StockNotifications\StockNotificationsFeatureTrait;
use WC_Helper_Product;
+use WC_Product_Attribute;
+use WC_Product_Variable;
+use WC_Product_Variation;
/**
* Tests for SignupService email dispatch.
@@ -132,6 +136,7 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
* @testdox Should detect an existing guest signup when the same email later signs up as a logged-in user.
*/
public function test_guest_signup_detected_for_logged_in_user_with_same_email() {
+ $this->disable_signup_rate_limiting();
update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );
$product = $this->create_out_of_stock_product();
@@ -154,6 +159,7 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
* @testdox Should detect an existing pending guest signup when the same email later signs up as a logged-in user with double opt-in enabled.
*/
public function test_pending_guest_signup_detected_for_logged_in_user_with_double_opt_in() {
+ $this->disable_signup_rate_limiting();
update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'yes' );
$product = $this->create_out_of_stock_product();
@@ -172,6 +178,7 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
* @testdox Should detect an existing logged-in signup when the same email later signs up as a guest.
*/
public function test_logged_in_signup_detected_for_guest_with_same_email() {
+ $this->disable_signup_rate_limiting();
update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );
$product = $this->create_out_of_stock_product();
@@ -189,6 +196,7 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
* @testdox Should detect an existing guest signup when the same email later signs up as a logged-in user with the same attributes.
*/
public function test_guest_signup_detected_for_logged_in_user_with_same_attributes() {
+ $this->disable_signup_rate_limiting();
update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );
$product = $this->create_out_of_stock_product();
@@ -463,7 +471,7 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
*/
private function create_out_of_stock_product(): \WC_Product_Simple {
$product = WC_Helper_Product::create_simple_product();
- $product->set_stock_status( 'outofstock' );
+ $product->set_stock_status( ProductStockStatus::OUT_OF_STOCK );
$product->save();
return $product;
@@ -473,6 +481,7 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
* @testdox A second signup with a different-case email should be reported as already joined.
*/
public function test_signup_dedupes_case_variants(): void {
+ $this->disable_signup_rate_limiting();
update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );
$product = $this->create_out_of_stock_product();
@@ -573,4 +582,257 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
$this->assertInstanceOf( \WP_Error::class, $data );
$this->assertSame( SignupService::ERROR_INVALID_EMAIL, $data->get_error_code() );
}
+
+ /**
+ * @testdox parse() should reject a posted variation attribute value the store does not offer.
+ */
+ public function test_parse_rejects_value_not_in_attribute_options(): void {
+ $variation = $this->create_out_of_stock_variation_with_any_attribute();
+
+ $data = $this->sut->parse(
+ array(
+ 'wc_bis_product_id' => $variation->get_id(),
+ 'wc_bis_email' => 'guest@example.com',
+ 'attribute_finish' => 'chrome',
+ )
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $data );
+ $this->assertSame( SignupService::ERROR_INVALID_REQUEST, $data->get_error_code() );
+ }
+
+ /**
+ * @testdox parse() should reject a posted variation attribute value over 255 characters.
+ */
+ public function test_parse_rejects_oversized_attribute_value(): void {
+ $value = str_repeat( 'a', 256 );
+ $variation = $this->create_out_of_stock_variation_with_any_attribute( array( $value ) );
+
+ $data = $this->sut->parse(
+ array(
+ 'wc_bis_product_id' => $variation->get_id(),
+ 'wc_bis_email' => 'guest@example.com',
+ 'attribute_finish' => $value,
+ )
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $data );
+ $this->assertSame( SignupService::ERROR_INVALID_REQUEST, $data->get_error_code() );
+ }
+
+ /**
+ * @testdox parse() should accept a posted variation attribute value at the 255 character limit.
+ */
+ public function test_parse_accepts_attribute_value_at_the_limit(): void {
+ $value = str_repeat( 'a', 255 );
+ $variation = $this->create_out_of_stock_variation_with_any_attribute( array( $value ) );
+
+ $data = $this->sut->parse(
+ array(
+ 'wc_bis_product_id' => $variation->get_id(),
+ 'wc_bis_email' => 'guest@example.com',
+ 'attribute_finish' => $value,
+ )
+ );
+
+ $this->assertIsArray( $data );
+ $this->assertArrayHasKey( 'posted_attributes', $data );
+ $this->assertSame( $value, $data['posted_attributes']['attribute_finish'] );
+ }
+
+ /**
+ * @testdox Should block an already-joined product by an existing rate limit window rather than report already-joined.
+ */
+ public function test_already_joined_is_blocked_by_an_existing_rate_limit_window(): void {
+ $email = 'guest@example.com';
+ $product_a = $this->create_out_of_stock_product();
+ $product_b = $this->create_out_of_stock_product();
+
+ $notification = new Notification();
+ $notification->set_status( NotificationStatus::ACTIVE );
+ $notification->set_product_id( $product_a->get_id() );
+ $notification->set_user_email( $email );
+ $notification->save();
+
+ $claims_window = $this->sut->signup( $product_b->get_id(), 0, $email );
+ $this->assertSame( SignupService::SIGNUP_SUCCESS, $claims_window->get_code(), 'Signing up for a different product should succeed and claim the rate limit window' );
+
+ $result = $this->sut->signup( $product_a->get_id(), 0, $email );
+ $this->assertWPError( $result, 'A request within the rate limit window should fail even for a product the customer already joined' );
+ $this->assertSame( SignupService::ERROR_RATE_LIMITED, $result->get_error_code(), 'The rate limit should be reported before the duplicate lookup runs' );
+ }
+
+ /**
+ * @testdox parse() should ignore a posted value for an attribute the variation fixes.
+ */
+ public function test_parse_ignores_posted_values_for_fixed_attributes(): void {
+ $variation = $this->create_out_of_stock_variation_with_fixed_and_any_attributes();
+
+ $data = $this->sut->parse(
+ array(
+ 'wc_bis_product_id' => $variation->get_id(),
+ 'wc_bis_email' => 'guest@example.com',
+ 'attribute_color' => 'blue',
+ 'attribute_finish' => 'gloss',
+ )
+ );
+
+ $this->assertIsArray( $data );
+ $this->assertSame( array( 'attribute_finish' => 'gloss' ), $data['posted_attributes'], 'The variation ID already identifies the attributes it fixes, so a posted value for one should be dropped' );
+ }
+
+ /**
+ * @testdox Should find an existing sign-up stored with the same posted attributes.
+ */
+ public function test_lookup_matches_the_same_posted_attributes(): void {
+ $product = $this->create_out_of_stock_product();
+ $email = 'guest@example.com';
+
+ $stored = $this->create_active_notification_with_attributes( $product->get_id(), $email, array( 'attribute_size' => 'large' ) );
+
+ $found = $this->sut->is_already_signed_up( $product->get_id(), 0, $email, array( 'attribute_size' => 'large' ) );
+
+ $this->assertInstanceOf( Notification::class, $found );
+ $this->assertSame( $stored->get_id(), $found->get_id() );
+ }
+
+ /**
+ * @testdox Should not find a sign-up stored with different posted attributes.
+ */
+ public function test_lookup_does_not_match_different_posted_attributes(): void {
+ $product = $this->create_out_of_stock_product();
+ $email = 'guest@example.com';
+
+ $this->create_active_notification_with_attributes( $product->get_id(), $email, array( 'attribute_size' => 'large' ) );
+
+ $this->assertNull( $this->sut->is_already_signed_up( $product->get_id(), 0, $email, array( 'attribute_size' => 'small' ) ), 'A different value should not match' );
+ $this->assertNull(
+ $this->sut->is_already_signed_up(
+ $product->get_id(),
+ 0,
+ $email,
+ array(
+ 'attribute_size' => 'large',
+ 'attribute_color' => 'blue',
+ )
+ ),
+ 'A superset of the stored attributes should not match'
+ );
+ }
+
+ /**
+ * @testdox Should match any sign-up for the identity when no attributes are posted.
+ */
+ public function test_lookup_without_posted_attributes_matches_any_signup(): void {
+ $product = $this->create_out_of_stock_product();
+ $email = 'guest@example.com';
+
+ $stored = $this->create_active_notification_with_attributes( $product->get_id(), $email, array( 'attribute_size' => 'large' ) );
+
+ $found = $this->sut->is_already_signed_up( $product->get_id(), 0, $email );
+
+ $this->assertInstanceOf( Notification::class, $found );
+ $this->assertSame( $stored->get_id(), $found->get_id() );
+ }
+
+ /**
+ * @testdox Should match posted attributes case sensitively.
+ */
+ public function test_lookup_matches_posted_attributes_case_sensitively(): void {
+ $product = $this->create_out_of_stock_product();
+ $email = 'guest@example.com';
+
+ $this->create_active_notification_with_attributes( $product->get_id(), $email, array( 'attribute_size' => 'Large' ) );
+
+ $this->assertNull( $this->sut->is_already_signed_up( $product->get_id(), 0, $email, array( 'attribute_size' => 'large' ) ), 'A value differing only in case should not match' );
+ $this->assertInstanceOf( Notification::class, $this->sut->is_already_signed_up( $product->get_id(), 0, $email, array( 'attribute_size' => 'Large' ) ) );
+ }
+
+ /**
+ * Create a variable product with one fixed and one "any" attribute, and an out-of-stock variation for it.
+ *
+ * @return WC_Product_Variation
+ */
+ private function create_out_of_stock_variation_with_fixed_and_any_attributes(): WC_Product_Variation {
+ $color = new WC_Product_Attribute();
+ $color->set_id( 0 );
+ $color->set_name( 'color' );
+ $color->set_options( array( 'red', 'blue' ) );
+ $color->set_visible( true );
+ $color->set_variation( true );
+
+ $finish = new WC_Product_Attribute();
+ $finish->set_id( 0 );
+ $finish->set_name( 'finish' );
+ $finish->set_options( array( 'gloss', 'matte' ) );
+ $finish->set_visible( true );
+ $finish->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Variable Product' );
+ $product->set_attributes( array( $color, $finish ) );
+ $product->save();
+
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $variation->set_attributes(
+ array(
+ 'color' => 'red',
+ 'finish' => '',
+ )
+ );
+ $variation->set_regular_price( '10' );
+ $variation->set_stock_status( ProductStockStatus::OUT_OF_STOCK );
+ $variation->save();
+
+ return $variation;
+ }
+
+ /**
+ * Create one active notification with the given posted attributes.
+ *
+ * @param int $product_id The product ID.
+ * @param string $email The user email.
+ * @param array $posted_attributes The posted attributes to store.
+ * @return Notification
+ */
+ private function create_active_notification_with_attributes( int $product_id, string $email, array $posted_attributes ): Notification {
+ $notification = new Notification();
+ $notification->set_status( NotificationStatus::ACTIVE );
+ $notification->set_product_id( $product_id );
+ $notification->set_user_email( $email );
+ $notification->update_meta_data( 'posted_attributes', $posted_attributes );
+ $notification->save();
+
+ return $notification;
+ }
+
+ /**
+ * Create a variable product with a custom "any" attribute, and one out-of-stock variation for it.
+ *
+ * @param array $extra_options Additional values to declare for the attribute.
+ * @return WC_Product_Variation
+ */
+ private function create_out_of_stock_variation_with_any_attribute( array $extra_options = array() ): WC_Product_Variation {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_id( 0 );
+ $attribute->set_name( 'finish' );
+ $attribute->set_options( array_merge( array( 'gloss', 'matte' ), $extra_options ) );
+ $attribute->set_visible( true );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Variable Product' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $variation->set_attributes( array( 'finish' => '' ) );
+ $variation->set_regular_price( '10' );
+ $variation->set_stock_status( ProductStockStatus::OUT_OF_STOCK );
+ $variation->save();
+
+ return $variation;
+ }
}