Commit bb1fbcc114f for woocommerce
commit bb1fbcc114f5c4c611353fb22b4ba1d0adafcbff
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date: Thu Oct 15 19:59:49 2026 +0300
Keep sent back-in-stock notifications from being cancelled by the unsubscribe link (#69499)
* fix: keep sent BIS notifications from being cancelled on unsubscribe
* test: cover cancelling a pending notification via unsubscribe link
* test: expect sent notifications to stay sent after the unsubscribe link in e2e
The back-in-stock email's unsubscribe link no longer cancels a sent notification, so the e2e tests now expect the already-sent notice and a Sent row. The tampered-key test's positive control checks for the same notice.
* style: fix Prettier indentation in back-in-stock e2e utils
* test: match the already-sent back-in-stock notice exactly
Store the notice as a plain string and pass { exact: true } to each
getByText call so text with extra content appended no longer matches.
diff --git a/plugins/woocommerce/changelog/fix-wooplug-7923-bis-unsubscribe-sent-row b/plugins/woocommerce/changelog/fix-wooplug-7923-bis-unsubscribe-sent-row
new file mode 100644
index 00000000000..2d2c66e1b9d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-7923-bis-unsubscribe-sent-row
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep back-in-stock notifications marked as sent when a customer clicks unsubscribe in an email they already received.
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailActionController.php b/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailActionController.php
index bedaef05d05..551d5f5a4a3 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailActionController.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailActionController.php
@@ -189,7 +189,7 @@ class EmailActionController {
}
/**
- * If the unsubscribe key matches, it updates the notification status to cancelled.
+ * If the unsubscribe key matches, it cancels the notification, unless it was already sent or cancelled.
*
* @param Notification $notification The Notification to process.
* @param string $action_key The action key to verify.
@@ -197,21 +197,29 @@ class EmailActionController {
*/
private function process_unsubscribe_action( Notification $notification, string $action_key ): void {
if ( $notification->check_unsubscribe_key( $action_key ) ) {
- $notification->set_status( NotificationStatus::CANCELLED );
- $notification->set_cancellation_source( NotificationCancellationSource::USER );
- $notification->set_date_cancelled( time() );
- $notification->save();
-
// We need a cookie-based session for notices to work on frontend pages.
if ( WC()->session instanceof \WC_Session_Handler && ! WC()->session->has_session() ) {
WC()->session->set_customer_session_cookie( true );
}
- $product = wc_get_product( $notification->get_product_id() );
+ $status = $notification->get_status();
- /* translators: %2$s product name, %1$s user email */
- $notice_text = sprintf( esc_html__( 'Successfully unsubscribed %1$s. You will not receive a notification when "%2$s" becomes available.', 'woocommerce' ), $notification->get_user_email(), $product->get_name() );
- wc_add_notice( $notice_text );
+ if ( NotificationStatus::SENT === $status ) {
+ wc_add_notice( esc_html__( 'This notification has already been sent, so there is nothing to unsubscribe from.', 'woocommerce' ), 'notice' );
+ } elseif ( NotificationStatus::ACTIVE !== $status && NotificationStatus::PENDING !== $status ) {
+ wc_add_notice( esc_html__( 'You are already unsubscribed from this notification.', 'woocommerce' ), 'notice' );
+ } else {
+ $notification->set_status( NotificationStatus::CANCELLED );
+ $notification->set_cancellation_source( NotificationCancellationSource::USER );
+ $notification->set_date_cancelled( time() );
+ $notification->save();
+
+ $product = wc_get_product( $notification->get_product_id() );
+
+ /* translators: %2$s product name, %1$s user email */
+ $notice_text = sprintf( esc_html__( 'Successfully unsubscribed %1$s. You will not receive a notification when "%2$s" becomes available.', 'woocommerce' ), $notification->get_user_email(), $product->get_name() );
+ wc_add_notice( $notice_text );
+ }
/**
* `woocommerce_customer_stock_notification_unsubscribe_redirect_url` filter.
*
diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts
index 248a708e0c5..11a120071f2 100644
--- a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts
@@ -143,7 +143,7 @@ test.describe(
).toHaveAttribute( 'href', /email_link_action=unsubscribe/ );
} );
- test( 'unsubscribe link in the back-in-stock email cancels the notification', async ( {
+ test( 'unsubscribe link in the back-in-stock email leaves the sent notification unchanged', async ( {
page,
product,
restApi,
@@ -172,14 +172,17 @@ test.describe(
// The shopper sees the outcome on the shop page they are sent to.
await expect(
- page.getByText( bisNotice.unsubscribed( email, product.name ) )
+ page.getByText( bisNotice.alreadySent, { exact: true } )
).toBeVisible();
await page.goto( bisAdminListUrl( product.id ) );
const row = page
.getByRole( 'row' )
.filter( { has: page.getByText( email, { exact: true } ) } );
- await expect( row.getByText( /Cancelled/i ) ).toBeVisible();
+ await expect(
+ row.getByText( 'Sent', { exact: true } )
+ ).toBeVisible();
+ await expect( row.getByText( /Cancelled/i ) ).toHaveCount( 0 );
} );
test( 'an unsubscribe link with a tampered key changes nothing', async ( {
@@ -207,7 +210,7 @@ test.describe(
// A rejected key is a no-op: no redirect to the shop, no notice.
await expect( page ).toHaveURL( /notification_id=/ );
await expect(
- page.getByText( bisNotice.unsubscribed( email, product.name ) )
+ page.getByText( bisNotice.alreadySent, { exact: true } )
).toHaveCount( 0 );
await page.goto( bisAdminListUrl( product.id ) );
@@ -217,12 +220,12 @@ test.describe(
await expect( row ).toHaveCount( 1 );
await expect( row.getByText( /Cancelled/i ) ).toHaveCount( 0 );
- // Positive control: the untouched link still unsubscribes, so the
- // row above survived because of the key and not because
- // unsubscribing is broken.
+ // Positive control: the untouched link is accepted, so the page
+ // above showed no notice because of the key and not because the
+ // link is broken.
await page.goto( unsubscribeLink );
await expect(
- page.getByText( bisNotice.unsubscribed( email, product.name ) )
+ page.getByText( bisNotice.alreadySent, { exact: true } )
).toBeVisible();
} );
}
diff --git a/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts b/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
index dd24c870b45..0f6baac3b38 100644
--- a/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
+++ b/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
@@ -272,7 +272,7 @@ export async function createOutOfStockVariableProduct(
{ name: BIS_VARIATION_ATTRIBUTE, option: '' },
],
},
- ]
+ ]
: [
{
regular_price: '9.99',
@@ -290,7 +290,7 @@ export async function createOutOfStockVariableProduct(
{ name: BIS_VARIATION_ATTRIBUTE, option: 'White' },
],
},
- ];
+ ];
const { data: batch } = await restApi.post< {
create: Array< { id: number; stock_status: string } >;
@@ -325,7 +325,7 @@ export async function createOutOfStockVariableProduct(
id: created[ 0 ].id,
option: 'Blue',
notificationName: notificationName( 'Blue' ),
- };
+ };
return {
id: product.id,
@@ -882,6 +882,12 @@ export const bisNotice = {
productName
) }" becomes available\\.`
),
+ /**
+ * Printed on the shop page after an unsubscribe link is followed for a
+ * notification that was already sent.
+ */
+ alreadySent:
+ 'This notification has already been sent, so there is nothing to unsubscribe from.',
errors: {
invalidEmail: /Invalid email address\./,
invalidProduct: /Invalid product\./,
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailActionControllerTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailActionControllerTests.php
index 1ec972d4a8f..15b7da12a8c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailActionControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailActionControllerTests.php
@@ -185,11 +185,14 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
}
/**
- * @testdox Should set notification status to cancelled and cancellation source to user on unsubscribe.
+ * @testdox Should set notification status to cancelled and cancellation source to user on unsubscribe from $status.
+ * @dataProvider provide_cancellable_statuses
+ *
+ * @param string $status Initial notification status.
*/
- public function test_process_unsubscribe_action_sets_status_cancelled() {
+ public function test_process_unsubscribe_action_sets_status_cancelled( $status ) {
$id = $this->arrange_notification(
- NotificationStatus::ACTIVE,
+ $status,
'unsubscribe_action_key',
wp_fast_hash( 'test' )
);
@@ -206,6 +209,67 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
$this->assertEquals( NotificationCancellationSource::USER, $updated->get_cancellation_source() );
}
+ /**
+ * Statuses an unsubscribe link can cancel.
+ *
+ * @return array
+ */
+ public function provide_cancellable_statuses() {
+ return array(
+ 'active' => array( NotificationStatus::ACTIVE ),
+ 'pending' => array( NotificationStatus::PENDING ),
+ );
+ }
+
+ /**
+ * @testdox Should leave a sent notification unchanged and show a neutral notice on unsubscribe.
+ */
+ public function test_process_unsubscribe_action_leaves_sent_notification_unchanged() {
+ $id = $this->arrange_notification( NotificationStatus::SENT, 'unsubscribe_action_key', wp_fast_hash( 'test' ) );
+ $notification = Factory::get_notification( $id );
+ $notification->set_date_notified( time() - DAY_IN_SECONDS );
+ $notification->save();
+ $date_notified = Factory::get_notification( $id )->get_date_notified();
+
+ try {
+ $this->sut->validate_and_maybe_process_request( $id, 'test', 'unsubscribe' );
+ $this->fail( 'Expected redirect to be intercepted via exception.' );
+ } catch ( \RuntimeException $e ) {
+ $this->assertStringContainsString( 'wp_redirect intercepted', $e->getMessage() );
+ }
+
+ $updated = Factory::get_notification( $id );
+ $this->assertEquals( NotificationStatus::SENT, $updated->get_status() );
+ $this->assertNotNull( $date_notified );
+ $this->assertEquals( $date_notified->getTimestamp(), $updated->get_date_notified()->getTimestamp() );
+ $this->assertNull( $updated->get_date_cancelled() );
+ $this->assertSame( 1, wc_notice_count( 'notice' ) );
+ $this->assertSame( 0, wc_notice_count( 'success' ) );
+ }
+
+ /**
+ * @testdox Should leave a cancelled notification unchanged and show a neutral notice on unsubscribe.
+ */
+ public function test_process_unsubscribe_action_leaves_cancelled_notification_unchanged() {
+ $id = $this->arrange_notification( NotificationStatus::CANCELLED, 'unsubscribe_action_key', wp_fast_hash( 'test' ) );
+ $notification = Factory::get_notification( $id );
+ $notification->set_cancellation_source( NotificationCancellationSource::ADMIN );
+ $notification->save();
+
+ try {
+ $this->sut->validate_and_maybe_process_request( $id, 'test', 'unsubscribe' );
+ $this->fail( 'Expected redirect to be intercepted via exception.' );
+ } catch ( \RuntimeException $e ) {
+ $this->assertStringContainsString( 'wp_redirect intercepted', $e->getMessage() );
+ }
+
+ $updated = Factory::get_notification( $id );
+ $this->assertEquals( NotificationStatus::CANCELLED, $updated->get_status() );
+ $this->assertEquals( NotificationCancellationSource::ADMIN, $updated->get_cancellation_source() );
+ $this->assertSame( 1, wc_notice_count( 'notice' ) );
+ $this->assertSame( 0, wc_notice_count( 'success' ) );
+ }
+
/**
* A verification request with a key that doesn't match the stored one must
* leave the notification untouched.