Commit bc88882a3d4 for woocommerce
commit bc88882a3d45452704f82098c9cd34729448cae2
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Wed Oct 7 15:31:01 2026 +0300
[tests] Pin how a shopper's shipping selection is stored (#69286)
* [tests] Cover the cart select-shipping-rate route storing and reflecting the chosen rate
* Add changefile(s) from automation for the following project(s): woocommerce
* [tests] Pin that a stale shipping selection is refused at checkout
* Add changefile(s) from automation for the following project(s): woocommerce
* [tests] Exercise two packages in the select-shipping-rate all-packages test
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/69286-wooplug-7845-shipping-tests-when-the-selection-changes b/plugins/woocommerce/changelog/69286-wooplug-7845-shipping-tests-when-the-selection-changes
new file mode 100644
index 00000000000..38ac61f5fd3
--- /dev/null
+++ b/plugins/woocommerce/changelog/69286-wooplug-7845-shipping-tests-when-the-selection-changes
@@ -0,0 +1,5 @@
+Significance: patch
+Type: dev
+Comment: Add tests for the cart select-shipping-rate route and stale-selection refusal; no production change.
+
+
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartSelectShippingRate.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartSelectShippingRate.php
new file mode 100644
index 00000000000..9c01f9ab160
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartSelectShippingRate.php
@@ -0,0 +1,215 @@
+<?php
+/**
+ * Controller tests for the cart select-shipping-rate route.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
+
+use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
+
+/**
+ * Behavioural tests for POST /cart/select-shipping-rate.
+ *
+ * The route stores which shipping rate a shopper chose and hands the cart back with that rate
+ * marked selected. The blocks drive it when a shopper picks an option, and nothing exercised it
+ * before: it appeared only in the route-registration lists. Expectations come from the route's own
+ * contract, a required rate id and a shipping-enabled store.
+ */
+class CartSelectShippingRate extends ControllerTestCase {
+
+ /**
+ * Instance ids of the two flat rates offered to the shopper.
+ *
+ * @var int[]
+ */
+ private $instances = array();
+
+ /**
+ * Give the Rest of the World zone two flat rates, a cart that needs shipping, and a US address
+ * so both rates are offered and one is selected by default.
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $zone = \WC_Shipping_Zones::get_zone( 0 );
+ $this->instances[] = $zone->add_shipping_method( 'flat_rate' );
+ $this->instances[] = $zone->add_shipping_method( 'flat_rate' );
+ update_option(
+ 'woocommerce_flat_rate_' . $this->instances[0] . '_settings',
+ array(
+ 'title' => 'First',
+ 'cost' => '5',
+ )
+ );
+ update_option(
+ 'woocommerce_flat_rate_' . $this->instances[1] . '_settings',
+ array(
+ 'title' => 'Second',
+ 'cost' => '9',
+ )
+ );
+ \WC_Cache_Helper::get_transient_version( 'shipping', true );
+
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Shippable',
+ 'regular_price' => 10,
+ 'weight' => 1,
+ )
+ );
+
+ wc_empty_cart();
+ WC()->customer->set_shipping_country( 'US' );
+ WC()->customer->set_shipping_state( 'CA' );
+ WC()->customer->set_shipping_city( 'Beverly Hills' );
+ WC()->customer->set_shipping_postcode( '90210' );
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+ WC()->cart->calculate_shipping();
+ WC()->cart->calculate_totals();
+ }
+
+ /**
+ * Reset the chosen method so it cannot leak into another test.
+ */
+ protected function tearDown(): void {
+ WC()->session->set( 'chosen_shipping_methods', array() );
+ parent::tearDown();
+ }
+
+ /**
+ * Post a selection and return the decoded cart response.
+ *
+ * @param array $body Request body.
+ * @return array{status:int,data:array}
+ */
+ private function select( array $body ): array {
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/select-shipping-rate' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params( $body );
+ $response = rest_get_server()->dispatch( $request );
+ return array(
+ 'status' => $response->get_status(),
+ 'data' => $response->get_data(),
+ );
+ }
+
+ /**
+ * Whether the given rate id is marked selected in a package of a cart response.
+ *
+ * @param array $data Cart response data.
+ * @param string $rate_id Rate id to look up.
+ * @param int $package_index Package to read, defaulting to the first.
+ * @return bool
+ */
+ private function is_selected( array $data, string $rate_id, int $package_index = 0 ): bool {
+ foreach ( $data['shipping_rates'][ $package_index ]['shipping_rates'] as $rate ) {
+ if ( $rate['rate_id'] === $rate_id ) {
+ return (bool) $rate['selected'];
+ }
+ }
+ $this->fail( 'Rate ' . $rate_id . ' was not offered in package ' . $package_index . '.' );
+ }
+
+ /**
+ * @testdox Choosing a rate stores it and the cart comes back with that rate selected and the other not.
+ */
+ public function test_selecting_a_rate_stores_it_and_marks_it_selected(): void {
+ $first = 'flat_rate:' . $this->instances[0];
+ $second = 'flat_rate:' . $this->instances[1];
+
+ $result = $this->select(
+ array(
+ 'package_id' => 0,
+ 'rate_id' => $second,
+ )
+ );
+
+ $this->assertSame( 200, $result['status'], 'A valid selection should succeed.' );
+ $this->assertTrue( $this->is_selected( $result['data'], $second ), 'The chosen rate should come back selected.' );
+ $this->assertFalse( $this->is_selected( $result['data'], $first ), 'The rate not chosen should not be selected.' );
+ $this->assertSame(
+ array( $second ),
+ WC()->session->get( 'chosen_shipping_methods' ),
+ 'The choice should be stored on the session, not only reflected in the response.'
+ );
+ }
+
+ /**
+ * With no package id the route selects the rate for every package. The cart is split into two
+ * packages first, so the all-packages branch is exercised against more than one and a
+ * regression that updated only the first package would be caught.
+ *
+ * @testdox Omitting the package id selects the rate for every package.
+ */
+ public function test_omitting_the_package_id_selects_the_rate_for_every_package(): void {
+ $second = 'flat_rate:' . $this->instances[1];
+
+ // Split the single cart package into two identical ones so "every package" means more than one.
+ $split = static function ( $packages ) {
+ if ( 1 === count( $packages ) ) {
+ $packages[] = $packages[0];
+ }
+ return $packages;
+ };
+ add_filter( 'woocommerce_cart_shipping_packages', $split );
+ WC()->cart->calculate_shipping();
+
+ try {
+ $result = $this->select( array( 'rate_id' => $second ) );
+ } finally {
+ remove_filter( 'woocommerce_cart_shipping_packages', $split );
+ }
+
+ $this->assertSame( 200, $result['status'], 'A selection without a package id should succeed.' );
+ $this->assertCount( 2, $result['data']['shipping_rates'], 'The cart should have split into two packages.' );
+ $this->assertTrue( $this->is_selected( $result['data'], $second, 0 ), 'The first package should carry the chosen rate.' );
+ $this->assertTrue( $this->is_selected( $result['data'], $second, 1 ), 'The second package should carry the chosen rate too.' );
+ $this->assertSame(
+ array( $second, $second ),
+ WC()->session->get( 'chosen_shipping_methods' ),
+ 'Every package should be stored with the chosen rate, not only the first.'
+ );
+ }
+
+ /**
+ * The rate id is a required argument, so a request without one is turned away by the route's
+ * argument contract before the handler runs.
+ *
+ * @testdox A selection with no rate id is refused as a missing required argument.
+ */
+ public function test_a_request_with_no_rate_id_is_refused(): void {
+ $result = $this->select( array( 'package_id' => 0 ) );
+
+ $this->assertSame( 400, $result['status'], 'A request without a rate id should be rejected.' );
+ $this->assertSame(
+ 'rest_missing_callback_param',
+ $result['data']['code'],
+ 'The required-argument contract should turn it away, before the handler.'
+ );
+ }
+
+ /**
+ * @testdox A selection is refused while shipping is switched off.
+ */
+ public function test_a_selection_is_refused_while_shipping_is_switched_off(): void {
+ $default = get_option( 'woocommerce_ship_to_countries', '' );
+ update_option( 'woocommerce_ship_to_countries', 'disabled' );
+
+ try {
+ $result = $this->select(
+ array(
+ 'package_id' => 0,
+ 'rate_id' => 'flat_rate:' . $this->instances[1],
+ )
+ );
+ } finally {
+ update_option( 'woocommerce_ship_to_countries', $default );
+ }
+
+ $this->assertSame( 404, $result['status'], 'With shipping disabled the route should refuse the selection.' );
+ $this->assertSame( 'woocommerce_rest_shipping_disabled', $result['data']['code'], 'And say why.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CheckoutStaleShippingRate.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CheckoutStaleShippingRate.php
new file mode 100644
index 00000000000..18bf699e73c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CheckoutStaleShippingRate.php
@@ -0,0 +1,106 @@
+<?php
+/**
+ * Tests for refusing a stale shipping selection at checkout.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
+
+use Automattic\WooCommerce\StoreApi\Utilities\OrderController;
+use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
+
+/**
+ * A shopper who forces a shipping rate that no longer exists must be refused at payment, not
+ * charged for it. OrderController::validate_selected_shipping_methods() is the gate: it throws when
+ * a chosen rate id is not among the rates the package actually offers. Nothing pinned the stale-id
+ * case before; the existing checkout test disables every method instead.
+ */
+class CheckoutStaleShippingRate extends ControllerTestCase {
+
+ /**
+ * Instance id of the flat rate the shopper really can choose.
+ *
+ * @var int
+ */
+ private $instance;
+
+ /**
+ * Give the Rest of the World zone a flat rate, a cart that needs shipping, and a US address, so
+ * the package offers exactly one real rate to validate against.
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $zone = \WC_Shipping_Zones::get_zone( 0 );
+ $this->instance = $zone->add_shipping_method( 'flat_rate' );
+ update_option(
+ 'woocommerce_flat_rate_' . $this->instance . '_settings',
+ array(
+ 'title' => 'Flat rate',
+ 'cost' => '5',
+ )
+ );
+ \WC_Cache_Helper::get_transient_version( 'shipping', true );
+
+ $product = \WC_Helper_Product::create_simple_product();
+ wc_empty_cart();
+ WC()->customer->set_shipping_country( 'US' );
+ WC()->customer->set_shipping_state( 'CA' );
+ WC()->customer->set_shipping_city( 'Beverly Hills' );
+ WC()->customer->set_shipping_postcode( '90210' );
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+ WC()->cart->calculate_shipping();
+ WC()->cart->calculate_totals();
+ }
+
+ /**
+ * Clear the chosen method so it cannot leak.
+ */
+ protected function tearDown(): void {
+ WC()->session->set( 'chosen_shipping_methods', array() );
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox The real rate the package offers passes validation.
+ */
+ public function test_the_offered_rate_passes_validation(): void {
+ $packages = WC()->shipping()->get_packages();
+ $rate_ids = wp_list_pluck( $packages[0]['rates'], 'id' );
+
+ // Prove setUp really put the rate on offer, so the no-throw below means something.
+ $this->assertContains(
+ 'flat_rate:' . $this->instance,
+ $rate_ids,
+ 'The cart built in setUp should offer the flat rate under test.'
+ );
+
+ // Throws on a rate the package does not offer; the offered rate must pass silently.
+ ( new OrderController() )->validate_selected_shipping_methods( true, array( 'flat_rate:' . $this->instance ) );
+ }
+
+ /**
+ * @testdox A chosen rate the package does not offer is refused.
+ */
+ public function test_a_stale_rate_is_refused(): void {
+ $controller = new OrderController();
+
+ // A method the zone never offers, with a prefix that cannot be a substring of the real
+ // flat_rate id, so the refusal cannot hinge on the auto-increment instance id.
+ $stale_rate = 'local_pickup:' . ( $this->instance + 999 );
+
+ $code = '';
+ try {
+ $controller->validate_selected_shipping_methods( true, array( $stale_rate ) );
+ } catch ( RouteException $e ) {
+ $code = $e->getErrorCode();
+ }
+
+ $this->assertSame(
+ 'woocommerce_rest_invalid_shipping_option',
+ $code,
+ 'Forcing a rate the package does not offer should be refused with the invalid-shipping-option error.'
+ );
+ }
+}