Commit 4f9c87a392d for woocommerce
commit 4f9c87a392d10a7884d0e1a139d04efee4743fc9
Author: Leonardo Lopes de Albuquerque <leonardo.albuquerque@automattic.com>
Date: Fri Mar 6 13:23:59 2026 -0300
[Backport 10.6.0]Add action hooks for cart item add, update, and remove events (#63371)
* Added internal use only hooks that will be used for the standalone fraud protection plugin.
* These hooks are fired as close as possible to the request handlers(controllers, ajax hooks, etc) so they don't get fired on cart manipulations from 3rd party plugins (Eg: PayPal express payment buttons).
* New hooks added for add, update, remove cart items.
diff --git a/plugins/woocommerce/changelog/dev-WOOSUBS-1439-cart-hooks-trunk b/plugins/woocommerce/changelog/dev-WOOSUBS-1439-cart-hooks-trunk
new file mode 100644
index 00000000000..e913a2049bc
--- /dev/null
+++ b/plugins/woocommerce/changelog/dev-WOOSUBS-1439-cart-hooks-trunk
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Added hooks for user triggered cart updates.
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 625d73e1cb4..2f78b8a3da2 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -530,6 +530,16 @@ class WC_AJAX {
do_action( 'woocommerce_ajax_added_to_cart', $product_id );
+ /**
+ * Fires when an item is added to the cart from a user request.
+ *
+ * @param int $product_id Product ID.
+ * @param int|float $quantity Quantity added to the cart.
+ *
+ * @since 10.6.0
+ */
+ do_action( 'internal_woocommerce_cart_item_added_from_user_request', $variation_id ? $variation_id : $product_id, $quantity );
+
if ( 'yes' === get_option( 'woocommerce_cart_redirect_after_add' ) ) {
wc_add_to_cart_message( array( $product_id => $quantity ), true );
}
@@ -560,7 +570,16 @@ class WC_AJAX {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$cart_item_key = wc_clean( isset( $_POST['cart_item_key'] ) ? wp_unslash( $_POST['cart_item_key'] ) : '' );
- if ( $cart_item_key && false !== WC()->cart->remove_cart_item( $cart_item_key ) ) {
+ if ( $cart_item_key && is_string( $cart_item_key ) && false !== WC()->cart->remove_cart_item( $cart_item_key ) ) {
+ /**
+ * Fires when an item is removed from the cart from a user request.
+ *
+ * @param string $cart_item_key Cart item key.
+ * @param \WC_Cart $cart Cart object.
+ *
+ * @since 10.6.0
+ */
+ do_action( 'internal_woocommerce_cart_item_removed_from_user_request', $cart_item_key, WC()->cart );
self::get_refreshed_fragments();
} else {
wp_send_json_error();
diff --git a/plugins/woocommerce/includes/class-wc-form-handler.php b/plugins/woocommerce/includes/class-wc-form-handler.php
index f1d9864e85e..72169baf096 100644
--- a/plugins/woocommerce/includes/class-wc-form-handler.php
+++ b/plugins/woocommerce/includes/class-wc-form-handler.php
@@ -654,7 +654,19 @@ class WC_Form_Handler {
$cart_item = WC()->cart->get_cart_item( $cart_item_key );
if ( $cart_item ) {
- WC()->cart->remove_cart_item( $cart_item_key );
+ $removed = WC()->cart->remove_cart_item( $cart_item_key );
+
+ if ( $removed ) {
+ /**
+ * Fires when a cart item is removed from a user request.
+ *
+ * @param string $cart_item_key Cart item key.
+ * @param \WC_Cart $cart Cart object.
+ *
+ * @since 10.6.0
+ */
+ do_action( 'internal_woocommerce_cart_item_removed_from_user_request', $cart_item_key, WC()->cart );
+ }
$product = wc_get_product( $cart_item['product_id'] );
@@ -725,8 +737,21 @@ class WC_Form_Handler {
}
if ( $passed_validation ) {
+ $old_quantity = $values['quantity'];
WC()->cart->set_quantity( $cart_item_key, $quantity, false );
$cart_updated = true;
+
+ /**
+ * Fires when a cart item quantity is updated from a user request.
+ *
+ * @param string $cart_item_key Cart item key.
+ * @param int|float $quantity New quantity.
+ * @param int|float $old_quantity Old quantity.
+ * @param \WC_Cart $cart Cart object.
+ *
+ * @since 10.6.0
+ */
+ do_action( 'internal_woocommerce_cart_item_updated_from_user_request', $cart_item_key, $quantity, $old_quantity, WC()->cart );
}
}
}
@@ -838,6 +863,7 @@ class WC_Form_Handler {
if ( ProductType::VARIABLE === $add_to_cart_handler || ProductType::VARIATION === $add_to_cart_handler ) {
$was_added_to_cart = self::add_to_cart_handler_variable( $product_id );
+ $product_id = ! empty( $_REQUEST['variation_id'] ) ? absint( wp_unslash( $_REQUEST['variation_id'] ) ) : $product_id; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
} elseif ( ProductType::GROUPED === $add_to_cart_handler ) {
$was_added_to_cart = self::add_to_cart_handler_grouped( $product_id );
} elseif ( has_action( 'woocommerce_add_to_cart_handler_' . $add_to_cart_handler ) ) {
@@ -848,6 +874,18 @@ class WC_Form_Handler {
// If we added the product to the cart we can now optionally do a redirect.
if ( $was_added_to_cart && 0 === wc_notice_count( 'error' ) ) {
+ $quantity = empty( $_REQUEST['quantity'] ) ? 1 : wc_stock_amount( wp_unslash( $_REQUEST['quantity'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+ /**
+ * Fires when an item is added to the cart from a user request.
+ *
+ * @param int $product_id Product ID.
+ * @param int|float $quantity Quantity added to the cart.
+ *
+ * @since 10.6.0
+ */
+ do_action( 'internal_woocommerce_cart_item_added_from_user_request', $product_id, $quantity );
+
$url = apply_filters( 'woocommerce_add_to_cart_redirect', $url, $adding_to_cart );
if ( $url ) {
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index ab0fdb312f3..1285a474439 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -8934,12 +8934,6 @@ parameters:
count: 1
path: includes/class-wc-ajax.php
- -
- message: '#^Parameter \#1 \$cart_item_key of method WC_Cart\:\:remove_cart_item\(\) expects string, array\|string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/class-wc-ajax.php
-
-
message: '#^Parameter \#1 \$coupon_code of method WC_Cart\:\:remove_coupon\(\) expects string, string\|false given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/CartAddItem.php b/plugins/woocommerce/src/StoreApi/Routes/V1/CartAddItem.php
index 8af82a15715..d87d57c985a 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/CartAddItem.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/CartAddItem.php
@@ -124,7 +124,24 @@ class CartAddItem extends AbstractCartRoute {
$request
);
- $this->cart_controller->add_to_cart( $add_to_cart_data );
+ $item_id = $this->cart_controller->add_to_cart( $add_to_cart_data );
+ $cart = $this->cart_controller->get_cart_instance();
+ $cart_item = $cart->get_cart_item( $item_id );
+
+ if ( ! empty( $cart_item ) ) {
+ $product_id = $cart_item['variation_id'] ? $cart_item['variation_id'] : $cart_item['product_id'];
+ $quantity = $add_to_cart_data['quantity'] ?? $cart_item['quantity'];
+
+ /**
+ * Fires when an item is added to the cart from a user request.
+ *
+ * @param int $product_id Product ID (variation ID for variable products).
+ * @param int|float $quantity Quantity added to the cart.
+ *
+ * @since 10.6.0
+ */
+ do_action( 'internal_woocommerce_cart_item_added_from_user_request', $product_id, $quantity );
+ }
$response = rest_ensure_response( $this->schema->get_item_response( $this->cart_controller->get_cart_for_response() ) );
$response->set_status( 201 );
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/CartRemoveItem.php b/plugins/woocommerce/src/StoreApi/Routes/V1/CartRemoveItem.php
index fa167dccefa..292cddd0fbd 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/CartRemoveItem.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/CartRemoveItem.php
@@ -73,7 +73,20 @@ class CartRemoveItem extends AbstractCartRoute {
throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item no longer exists or is invalid.', 'woocommerce' ), 409 );
}
- $cart->remove_cart_item( $request['key'] );
+ $removed = $cart->remove_cart_item( $request['key'] );
+
+ if ( $removed ) {
+ /**
+ * Fires when a cart item is removed from a user request.
+ *
+ * @param string $cart_item_key Cart item key.
+ * @param \WC_Cart $cart Cart object.
+ *
+ * @since 10.6.0
+ */
+ do_action( 'internal_woocommerce_cart_item_removed_from_user_request', $request['key'], $cart );
+ }
+
$this->maybe_release_stock();
return rest_ensure_response( $this->schema->get_item_response( $cart ) );
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/CartUpdateItem.php b/plugins/woocommerce/src/StoreApi/Routes/V1/CartUpdateItem.php
index aefd9e73f7a..a71a960f7f3 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/CartUpdateItem.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/CartUpdateItem.php
@@ -71,7 +71,23 @@ class CartUpdateItem extends AbstractCartRoute {
$cart = $this->cart_controller->get_cart_instance();
if ( isset( $request['quantity'] ) ) {
+ $cart_item = $cart->get_cart_item( $request['key'] );
+ $old_quantity = $cart_item['quantity'] ?? 0;
$this->cart_controller->set_cart_item_quantity( $request['key'], $request['quantity'] );
+
+ if ( $old_quantity !== (int) $request['quantity'] ) {
+ /**
+ * Fires when a cart item quantity is updated from a user request.
+ *
+ * @param string $cart_item_key Cart item key.
+ * @param int $quantity New quantity.
+ * @param int|float $old_quantity Old quantity.
+ * @param \WC_Cart $cart Cart object.
+ *
+ * @since 10.6.0
+ */
+ do_action( 'internal_woocommerce_cart_item_updated_from_user_request', $request['key'], (int) $request['quantity'], $old_quantity, $cart );
+ }
}
return rest_ensure_response( $this->schema->get_item_response( $cart ) );
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index 3884f0e8c15..9372fb92be5 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -314,6 +314,115 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
);
}
+ /**
+ * @testdox Should fire internal_woocommerce_cart_item_added_from_user_request when adding an item via AJAX.
+ */
+ public function test_add_to_cart_fires_cart_item_added_from_user_request(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $_POST['product_id'] = $product->get_id();
+ $_POST['quantity'] = 3;
+
+ $captured_args = array();
+ $callback = function ( $product_id, $quantity ) use ( &$captured_args ) {
+ $captured_args = array(
+ 'product_id' => $product_id,
+ 'quantity' => $quantity,
+ );
+ };
+
+ add_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback, 10, 2 );
+
+ $this->do_ajax( 'woocommerce_add_to_cart' );
+
+ $this->assertNotEmpty( $captured_args, 'The action should have been fired' );
+ $this->assertSame( $product->get_id(), $captured_args['product_id'] );
+ $this->assertEquals( 3, $captured_args['quantity'] );
+
+ remove_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback );
+
+ WC()->cart->empty_cart();
+ unset( $_POST['product_id'], $_POST['quantity'] );
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Should fire internal_woocommerce_cart_item_added_from_user_request with variation ID when adding a variation via AJAX.
+ */
+ public function test_add_to_cart_fires_cart_item_added_from_user_request_for_variation(): void {
+ $product = new \WC_Product_Variable();
+ $product->set_name( 'Test Variable Product' );
+ $attribute = WC_Helper_Product::create_product_attribute_object( 'color', array( 'blue' ) );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $variation = new \WC_Product_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $variation->set_attributes( array( 'pa_color' => 'blue' ) );
+ $variation->set_regular_price( 10 );
+ $variation->save();
+
+ $_POST['product_id'] = $variation->get_id();
+ $_POST['quantity'] = 2;
+
+ $captured_args = array();
+ $callback = function ( $product_id, $quantity ) use ( &$captured_args ) {
+ $captured_args = array(
+ 'product_id' => $product_id,
+ 'quantity' => $quantity,
+ );
+ };
+
+ add_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback, 10, 2 );
+
+ $this->do_ajax( 'woocommerce_add_to_cart' );
+
+ $this->assertNotEmpty( $captured_args, 'The action should have been fired' );
+ $this->assertSame( $variation->get_id(), $captured_args['product_id'], 'The product_id should be the variation ID, not the parent product ID' );
+ $this->assertEquals( 2, $captured_args['quantity'] );
+
+ remove_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback );
+
+ WC()->cart->empty_cart();
+ unset( $_POST['product_id'], $_POST['quantity'] );
+ $variation->delete( true );
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Should fire internal_woocommerce_cart_item_removed_from_user_request when removing an item via AJAX.
+ */
+ public function test_remove_from_cart_fires_cart_item_removed_from_user_request(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ WC()->cart->empty_cart();
+ $cart_item_key = WC()->cart->add_to_cart( $product->get_id(), 1 );
+
+ $_POST['cart_item_key'] = $cart_item_key;
+
+ $captured_args = array();
+ $callback = function ( $key, $cart ) use ( &$captured_args ) {
+ $captured_args = array(
+ 'cart_item_key' => $key,
+ 'cart' => $cart,
+ );
+ };
+
+ add_action( 'internal_woocommerce_cart_item_removed_from_user_request', $callback, 10, 2 );
+
+ $this->do_ajax( 'woocommerce_remove_from_cart' );
+
+ $this->assertNotEmpty( $captured_args, 'The action should have been fired' );
+ $this->assertSame( $cart_item_key, $captured_args['cart_item_key'] );
+ $this->assertInstanceOf( WC_Cart::class, $captured_args['cart'] );
+
+ remove_action( 'internal_woocommerce_cart_item_removed_from_user_request', $callback );
+
+ WC()->cart->empty_cart();
+ unset( $_POST['cart_item_key'] );
+ $product->delete( true );
+ }
+
/**
* Does the 'hard work' of triggering an ajax endpoint and capturing the response.
*
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
index 635fdb1bba4..8ebb39c1309 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -1224,4 +1224,196 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
$product->delete( true );
wp_delete_user( $user_id );
}
+
+ /**
+ * @testdox Should fire internal_woocommerce_cart_item_updated_from_user_request when cart item quantity is updated via form.
+ */
+ public function test_update_cart_action_fires_update_quantity_action(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ WC()->cart->add_to_cart( $product->get_id(), 2 );
+
+ $cart_items = WC()->cart->get_cart();
+ $cart_item_key = array_key_first( $cart_items );
+
+ $nonce = wp_create_nonce( 'woocommerce-cart' );
+
+ $_POST['_wpnonce'] = $nonce;
+ $_REQUEST['_wpnonce'] = $nonce;
+ $_POST['update_cart'] = 'Update Cart';
+ $_REQUEST['update_cart'] = 'Update Cart';
+ $_POST['cart'] = array(
+ $cart_item_key => array( 'qty' => 5 ),
+ );
+
+ $captured_args = array();
+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ $callback = function ( $cart_item_key, $quantity, $old_quantity, $cart ) use ( &$captured_args ) {
+ $captured_args = compact( 'cart_item_key', 'quantity', 'old_quantity', 'cart' );
+ };
+
+ add_action( 'internal_woocommerce_cart_item_updated_from_user_request', $callback, 10, 4 );
+
+ WC_Form_Handler::update_cart_action();
+
+ $this->assertNotEmpty( $captured_args, 'The update quantity action should have been fired' );
+ $this->assertSame( $cart_item_key, $captured_args['cart_item_key'] );
+ $this->assertEquals( 5, $captured_args['quantity'] );
+ $this->assertEquals( 2, $captured_args['old_quantity'] );
+ $this->assertInstanceOf( WC_Cart::class, $captured_args['cart'] );
+
+ remove_action( 'internal_woocommerce_cart_item_updated_from_user_request', $callback );
+
+ unset( $_POST['_wpnonce'], $_REQUEST['_wpnonce'], $_POST['update_cart'], $_REQUEST['update_cart'], $_POST['cart'] );
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Should not fire internal_woocommerce_cart_item_updated_from_user_request when quantity is unchanged.
+ */
+ public function test_update_cart_action_does_not_fire_update_quantity_action_when_unchanged(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ WC()->cart->add_to_cart( $product->get_id(), 2 );
+
+ $cart_items = WC()->cart->get_cart();
+ $cart_item_key = array_key_first( $cart_items );
+
+ $nonce = wp_create_nonce( 'woocommerce-cart' );
+
+ $_POST['_wpnonce'] = $nonce;
+ $_REQUEST['_wpnonce'] = $nonce;
+ $_POST['update_cart'] = 'Update Cart';
+ $_REQUEST['update_cart'] = 'Update Cart';
+ $_POST['cart'] = array(
+ $cart_item_key => array( 'qty' => 2 ),
+ );
+
+ $hook_fired = false;
+ $callback = function () use ( &$hook_fired ) {
+ $hook_fired = true;
+ };
+
+ add_action( 'internal_woocommerce_cart_item_updated_from_user_request', $callback, 10, 4 );
+
+ WC_Form_Handler::update_cart_action();
+
+ $this->assertFalse( $hook_fired, 'The update quantity action should not fire when the quantity is unchanged' );
+
+ remove_action( 'internal_woocommerce_cart_item_updated_from_user_request', $callback );
+
+ unset( $_POST['_wpnonce'], $_REQUEST['_wpnonce'], $_POST['update_cart'], $_REQUEST['update_cart'], $_POST['cart'] );
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Should fire internal_woocommerce_cart_item_removed_from_user_request when cart item is removed via form.
+ */
+ public function test_update_cart_action_fires_remove_item_action(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+
+ $cart_items = WC()->cart->get_cart();
+ $cart_item_key = array_key_first( $cart_items );
+
+ $nonce = wp_create_nonce( 'woocommerce-cart' );
+
+ $_REQUEST['_wpnonce'] = $nonce;
+ $_GET['remove_item'] = $cart_item_key;
+ $_REQUEST['remove_item'] = $cart_item_key;
+
+ $captured_args = array();
+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ $callback = function ( $cart_item_key, $cart ) use ( &$captured_args ) {
+ $captured_args = compact( 'cart_item_key', 'cart' );
+ };
+
+ add_action( 'internal_woocommerce_cart_item_removed_from_user_request', $callback, 10, 2 );
+
+ WC_Form_Handler::update_cart_action();
+
+ $this->assertNotEmpty( $captured_args, 'The remove item action should have been fired' );
+ $this->assertSame( $cart_item_key, $captured_args['cart_item_key'] );
+ $this->assertInstanceOf( WC_Cart::class, $captured_args['cart'] );
+
+ remove_action( 'internal_woocommerce_cart_item_removed_from_user_request', $callback );
+
+ unset( $_REQUEST['_wpnonce'], $_GET['remove_item'], $_REQUEST['remove_item'] );
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Should fire internal_woocommerce_cart_item_added_from_user_request when a simple product is added via the shortcode form.
+ */
+ public function test_add_to_cart_action_fires_cart_item_added_from_user_request(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $_REQUEST['add-to-cart'] = $product->get_id();
+ $_REQUEST['quantity'] = 3;
+ $_POST['quantity'] = 3;
+
+ $captured_args = array();
+ $callback = function ( $product_id, $quantity ) use ( &$captured_args ) {
+ $captured_args = array(
+ 'product_id' => $product_id,
+ 'quantity' => $quantity,
+ );
+ };
+
+ add_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback, 10, 2 );
+
+ WC_Form_Handler::add_to_cart_action( false );
+
+ $this->assertNotEmpty( $captured_args, 'The action should have been fired' );
+ $this->assertSame( $product->get_id(), $captured_args['product_id'] );
+ $this->assertEquals( 3, $captured_args['quantity'] );
+
+ remove_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback );
+
+ unset( $_REQUEST['add-to-cart'], $_REQUEST['quantity'], $_POST['quantity'] );
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Should fire internal_woocommerce_cart_item_added_from_user_request with the variation ID when a variable product is added via the shortcode form.
+ */
+ public function test_add_to_cart_action_fires_cart_item_added_from_user_request_for_variable_product(): void {
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Test Variable Product' );
+ $attribute = WC_Helper_Product::create_product_attribute_object( 'color', array( 'blue' ) );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $variation->set_attributes( array( 'pa_color' => 'blue' ) );
+ $variation->set_regular_price( 10 );
+ $variation->save();
+
+ $_REQUEST['add-to-cart'] = $product->get_id();
+ $_REQUEST['variation_id'] = $variation->get_id();
+ $_REQUEST['quantity'] = 2;
+ $_POST['quantity'] = 2;
+ $_REQUEST['attribute_pa_color'] = 'blue';
+
+ $captured_args = array();
+ $callback = function ( $product_id, $quantity ) use ( &$captured_args ) {
+ $captured_args = array(
+ 'product_id' => $product_id,
+ 'quantity' => $quantity,
+ );
+ };
+
+ add_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback, 10, 2 );
+
+ WC_Form_Handler::add_to_cart_action( false );
+
+ $this->assertNotEmpty( $captured_args, 'The action should have been fired' );
+ $this->assertSame( $variation->get_id(), $captured_args['product_id'], 'The product_id should be the variation ID, not the parent product ID' );
+ $this->assertEquals( 2, $captured_args['quantity'] );
+
+ remove_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback );
+
+ unset( $_REQUEST['add-to-cart'], $_REQUEST['variation_id'], $_REQUEST['quantity'], $_POST['quantity'], $_REQUEST['attribute_pa_color'] );
+ $variation->delete( true );
+ $product->delete( true );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
index b3b4c295f1c..de1419b10d0 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
@@ -1085,4 +1085,234 @@ class Cart extends ControllerTestCase {
)
);
}
+
+ /**
+ * @testdox Should fire internal_woocommerce_cart_item_added_from_user_request when adding an item.
+ */
+ public function test_add_item_fires_add_action(): void {
+ wc_empty_cart();
+
+ $captured_args = array();
+ $callback = function ( $product_id, $quantity ) use ( &$captured_args ) {
+ $captured_args = array(
+ 'product_id' => $product_id,
+ 'quantity' => $quantity,
+ );
+ };
+
+ add_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback, 10, 2 );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/add-item' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params(
+ array(
+ 'id' => $this->products[0]->get_id(),
+ 'quantity' => 2,
+ )
+ );
+
+ $this->assertAPIResponse( $request, 201 );
+
+ $this->assertNotEmpty( $captured_args, 'The add action should have been fired' );
+ $this->assertSame( $this->products[0]->get_id(), $captured_args['product_id'] );
+ $this->assertEquals( 2, $captured_args['quantity'] );
+
+ remove_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback );
+ }
+
+ /**
+ * @testdox Should fire internal_woocommerce_cart_item_added_from_user_request with default quantity of 1 when quantity is omitted.
+ */
+ public function test_add_item_fires_add_action_when_quantity_omitted(): void {
+ wc_empty_cart();
+
+ $captured_args = array();
+ $callback = function ( $product_id, $quantity ) use ( &$captured_args ) {
+ $captured_args = array(
+ 'product_id' => $product_id,
+ 'quantity' => $quantity,
+ );
+ };
+
+ add_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback, 10, 2 );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/add-item' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params(
+ array(
+ 'id' => $this->products[0]->get_id(),
+ )
+ );
+
+ $this->assertAPIResponse( $request, 201 );
+
+ $this->assertNotEmpty( $captured_args, 'The add action should have been fired' );
+ $this->assertSame( $this->products[0]->get_id(), $captured_args['product_id'] );
+ $this->assertEquals( 1, $captured_args['quantity'] );
+
+ remove_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback );
+ }
+
+ /**
+ * @testdox Should fire internal_woocommerce_cart_item_updated_from_user_request when updating item quantity.
+ */
+ public function test_update_item_fires_update_action(): void {
+ $captured_args = array();
+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ $callback = function ( $cart_item_key, $quantity, $old_quantity, $cart ) use ( &$captured_args ) {
+ $captured_args = compact( 'cart_item_key', 'quantity', 'old_quantity', 'cart' );
+ };
+
+ add_action( 'internal_woocommerce_cart_item_updated_from_user_request', $callback, 10, 4 );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/update-item' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params(
+ array(
+ 'key' => $this->keys[0],
+ 'quantity' => 5,
+ )
+ );
+
+ $this->assertAPIResponse( $request, 200 );
+
+ $this->assertNotEmpty( $captured_args, 'The update action should have been fired' );
+ $this->assertSame( $this->keys[0], $captured_args['cart_item_key'] );
+ $this->assertEquals( 5, $captured_args['quantity'] );
+ $this->assertEquals( 2, $captured_args['old_quantity'] );
+ $this->assertInstanceOf( \WC_Cart::class, $captured_args['cart'] );
+
+ remove_action( 'internal_woocommerce_cart_item_updated_from_user_request', $callback );
+ }
+
+ /**
+ * @testdox Should fire internal_woocommerce_cart_item_removed_from_user_request when removing a cart item.
+ */
+ public function test_remove_item_fires_remove_action(): void {
+ $captured_args = array();
+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ $callback = function ( $cart_item_key, $cart ) use ( &$captured_args ) {
+ $captured_args = compact( 'cart_item_key', 'cart' );
+ };
+
+ add_action( 'internal_woocommerce_cart_item_removed_from_user_request', $callback, 10, 2 );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/remove-item' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params(
+ array(
+ 'key' => $this->keys[0],
+ )
+ );
+
+ $this->assertAPIResponse( $request, 200 );
+
+ $this->assertNotEmpty( $captured_args, 'The remove action should have been fired' );
+ $this->assertSame( $this->keys[0], $captured_args['cart_item_key'] );
+ $this->assertInstanceOf( \WC_Cart::class, $captured_args['cart'] );
+
+ remove_action( 'internal_woocommerce_cart_item_removed_from_user_request', $callback );
+ }
+
+ /**
+ * @testdox Should not fire internal_woocommerce_cart_item_updated_from_user_request when quantity is unchanged.
+ */
+ public function test_update_item_with_same_quantity_does_not_fire_update_action(): void {
+ $action_fired = false;
+ $callback = function () use ( &$action_fired ) {
+ $action_fired = true;
+ };
+
+ add_action( 'internal_woocommerce_cart_item_updated_from_user_request', $callback, 10, 4 );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/update-item' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params(
+ array(
+ 'key' => $this->keys[0],
+ 'quantity' => 2,
+ )
+ );
+
+ $this->assertAPIResponse( $request, 200 );
+
+ $this->assertFalse( $action_fired, 'The update action should not fire when quantity is unchanged' );
+
+ remove_action( 'internal_woocommerce_cart_item_updated_from_user_request', $callback );
+ }
+
+ /**
+ * @testdox Should fire internal_woocommerce_cart_item_added_from_user_request with the variation ID when adding a variable product.
+ */
+ public function test_add_item_fires_add_action_with_variation_id(): void {
+ wc_empty_cart();
+
+ $fixtures = new FixtureData();
+ $attribute = $fixtures->get_product_attribute( 'color', array( 'blue' ) );
+ $product = $fixtures->get_variable_product(
+ array(
+ 'name' => 'Test Variable Product',
+ ),
+ array( $attribute )
+ );
+
+ $variation = new \WC_Product_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $variation->set_attributes( array( 'pa_color' => 'blue' ) );
+ $variation->set_regular_price( 10 );
+ $variation->save();
+
+ $captured_args = array();
+ $callback = function ( $product_id, $quantity ) use ( &$captured_args ) {
+ $captured_args = array(
+ 'product_id' => $product_id,
+ 'quantity' => $quantity,
+ );
+ };
+
+ add_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback, 10, 2 );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/add-item' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params(
+ array(
+ 'id' => $variation->get_id(),
+ 'quantity' => 1,
+ )
+ );
+
+ $this->assertAPIResponse( $request, 201 );
+
+ $this->assertNotEmpty( $captured_args, 'The add action should have been fired' );
+ $this->assertSame( $variation->get_id(), $captured_args['product_id'], 'The product_id should be the variation ID, not the parent product ID' );
+ $this->assertEquals( 1, $captured_args['quantity'] );
+
+ remove_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback );
+ }
+
+ /**
+ * @testdox Should not fire internal_woocommerce_cart_item_updated_from_user_request when quantity is not set.
+ */
+ public function test_update_item_without_quantity_does_not_fire_update_action(): void {
+ $action_fired = false;
+ $callback = function () use ( &$action_fired ) {
+ $action_fired = true;
+ };
+
+ add_action( 'internal_woocommerce_cart_item_updated_from_user_request', $callback, 10, 4 );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/update-item' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params(
+ array(
+ 'key' => $this->keys[0],
+ )
+ );
+
+ $this->assertAPIResponse( $request, 200 );
+
+ $this->assertFalse( $action_fired, 'The update action should not fire when quantity is not set' );
+
+ remove_action( 'internal_woocommerce_cart_item_updated_from_user_request', $callback );
+ }
}