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.