Commit d346a0542f5 for woocommerce

commit d346a0542f532e709a6df513cbcecef41dde68c1
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Wed Oct 7 15:31:20 2026 +0300

    [tests] Pin the rate flat rate emits from its cost settings (#69197)

    * [tests] Pin the rate flat rate emits from its cost settings

    * [tests] Let the changelog workflow create the entry

    * Add changefile(s) from automation for the following project(s): woocommerce

    * [tests] Cover the default and blank values of the flat rate cost settings

    * [tests] Pin that class costs see only their own class, and skip what is not shipped

    * [tests] List the dearer shipping class first so per-order charging is really pinned

    * Add changefile(s) from automation for the following project(s): woocommerce

    * [tests] Describe the needs_shipping re-checks instead of promising a rule no screen makes

    * [tests] Explain the class cost grouping instead of arguing from the shared field description

    * [tests] Pin that a method with nothing filled in offers no rate, and explain the 2.5.0 key rename

    * [tests] Pin the class cost combinations, the weight-driven fee and how an amount is stored

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/69197-wooplug-7838-shipping-tests-flat-rate-cost b/plugins/woocommerce/changelog/69197-wooplug-7838-shipping-tests-flat-rate-cost
new file mode 100644
index 00000000000..02c12854ab9
--- /dev/null
+++ b/plugins/woocommerce/changelog/69197-wooplug-7838-shipping-tests-flat-rate-cost
@@ -0,0 +1,5 @@
+Significance: patch
+Type: dev
+Comment: Cover the rate flat rate emits from its cost settings; no production change.
+
+
diff --git a/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-rates-test.php b/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-rates-test.php
new file mode 100644
index 00000000000..418165308eb
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-rates-test.php
@@ -0,0 +1,715 @@
+<?php
+declare( strict_types = 1 );
+
+// phpcs:disable Squiz.Classes.ValidClassName.NotCamelCaps -- backcompat nomenclature.
+
+/**
+ * Tests the rate WC_Shipping_Flat_Rate emits for a package.
+ *
+ * The sibling test file covers evaluate_cost() and sanitize_cost() in isolation, with their
+ * arguments passed in by hand. These go through calculate_shipping() instead, so they pin what a
+ * merchant's saved setting actually charges the shopper.
+ *
+ * Most expected values are taken from what the settings screen promises the merchant, in
+ * includes/shipping/flat-rate/includes/settings-flat-rate.php, rather than from reading the
+ * implementation.
+ *
+ * The sibling file holds the name this class would otherwise take, hence the suffix here.
+ */
+class WC_Shipping_Flat_Rate_Rates_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * Shipping zone holding the method under test.
+	 *
+	 * @var WC_Shipping_Zone
+	 */
+	private $zone;
+
+	/**
+	 * Instance id of the flat rate method in that zone.
+	 *
+	 * @var int
+	 */
+	private $instance_id;
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var WC_Shipping_Flat_Rate
+	 */
+	private $sut;
+
+	/**
+	 * Set up test case.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		// WC_Shipping caches the class list on the singleton and no base class resets it, so a
+		// class created by an earlier test would otherwise still be the list this one sees.
+		WC()->shipping()->shipping_classes = array();
+
+		$this->zone = new WC_Shipping_Zone();
+		$this->zone->set_zone_name( 'Flat rate rates' );
+		$this->zone->add_location( 'US', 'country' );
+		$this->zone->save();
+
+		$this->instance_id = $this->zone->add_shipping_method( 'flat_rate' );
+	}
+
+	/**
+	 * Tear down test case.
+	 */
+	public function tearDown(): void {
+		try {
+			// Released on the way out as well as in, so the dead terms this file creates are not
+			// still memoized for whatever test file runs next in the same process.
+			WC()->shipping()->shipping_classes = array();
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * Save instance settings and return the method that reads them.
+	 *
+	 * Call this after creating any shipping class the test needs. The class cost fields only
+	 * exist while classes do, and get_option() silently reads the store-wide settings for keys
+	 * that are not instance fields, so building the method first makes the reads go elsewhere.
+	 *
+	 * @param array $settings Instance settings to store.
+	 */
+	private function method_with( array $settings ): void {
+		update_option(
+			'woocommerce_flat_rate_' . $this->instance_id . '_settings',
+			array_merge(
+				array(
+					'title'      => 'Flat rate',
+					'tax_status' => 'taxable',
+					'type'       => 'class',
+				),
+				$settings
+			)
+		);
+
+		$this->sut = new WC_Shipping_Flat_Rate( $this->instance_id );
+	}
+
+	/**
+	 * Build a package from products.
+	 *
+	 * @param array $items         Each entry is array( product, quantity, line_total ).
+	 * @param float $contents_cost Package contents cost, which feeds the [cost] placeholder.
+	 * @return array
+	 */
+	private function package_of( array $items, float $contents_cost ): array {
+		$contents = array();
+
+		foreach ( $items as $index => $item ) {
+			$contents[ 'item_' . $index ] = array(
+				'data'       => $item['product'],
+				'quantity'   => $item['quantity'],
+				'line_total' => $item['line_total'],
+			);
+		}
+
+		return array(
+			'contents'      => $contents,
+			'contents_cost' => $contents_cost,
+			'destination'   => array(
+				'country'  => 'US',
+				'state'    => 'CA',
+				'postcode' => '90210',
+			),
+		);
+	}
+
+	/**
+	 * Run the configured method against a package and return the rate it emitted.
+	 *
+	 * @param array $package Package to rate.
+	 * @return WC_Shipping_Rate
+	 */
+	private function rate_for( array $package ): WC_Shipping_Rate {
+		$this->assertCount( 1, $this->rates_for( $package ), 'The method should offer the shopper a rate.' );
+
+		return current( $this->sut->rates );
+	}
+
+	/**
+	 * The rates the method offers for a package, however many that is.
+	 *
+	 * @param array $package Package to calculate against.
+	 * @return array
+	 */
+	private function rates_for( array $package ): array {
+		$this->sut->calculate_shipping( $package );
+
+		return $this->sut->rates;
+	}
+
+	/**
+	 * Create a product that ships.
+	 *
+	 * @param string $shipping_class Shipping class slug, or an empty string for none.
+	 * @param float  $weight         Product weight, or zero for none.
+	 * @param bool   $virtual        Whether the product is virtual, so nothing is shipped for it.
+	 * @return WC_Product_Simple
+	 */
+	private function shippable_product( string $shipping_class = '', float $weight = 0, bool $virtual = false ): WC_Product_Simple {
+		$product = new WC_Product_Simple();
+		$product->set_regular_price( '10' );
+		$product->set_virtual( $virtual );
+
+		if ( $weight > 0 ) {
+			$product->set_weight( (string) $weight );
+		}
+
+		if ( '' !== $shipping_class ) {
+			$term = wp_insert_term( $shipping_class, 'product_shipping_class' );
+			$this->assertNotWPError( $term, 'The test fixture should be able to create a shipping class.' );
+			$product->set_shipping_class_id( (int) $term['term_id'] );
+
+			WC()->shipping()->shipping_classes = array();
+		}
+
+		$product->save();
+
+		return $product;
+	}
+
+	/**
+	 * get_package_item_qty() re-checks needs_shipping() rather than trusting the package, so an
+	 * item that does not need shipping is not counted even when the package contains one.
+	 *
+	 * @testdox The [qty] placeholder skips an item that does not need shipping.
+	 */
+	public function test_qty_placeholder_counts_only_shippable_items(): void {
+		$virtual = new WC_Product_Simple();
+		$virtual->set_regular_price( '10' );
+		$virtual->set_virtual( true );
+		$virtual->save();
+
+		$this->method_with( array( 'cost' => '[qty]' ) );
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $this->shippable_product(),
+					'quantity'   => 2,
+					'line_total' => 20.0,
+				),
+				array(
+					'product'    => $virtual,
+					'quantity'   => 5,
+					'line_total' => 50.0,
+				),
+			),
+			70.0
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals( 2, $rate->get_cost(), 'Only the two shippable units should be counted.' );
+	}
+
+	/**
+	 * The screen calls [cost] "total cost of items".
+	 *
+	 * @testdox The [cost] placeholder is the total cost of the items in the package.
+	 */
+	public function test_cost_placeholder_is_the_total_cost_of_items(): void {
+		$this->method_with( array( 'cost' => '[cost]' ) );
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $this->shippable_product(),
+					'quantity'   => 1,
+					'line_total' => 42.5,
+				),
+			),
+			42.5
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals( 42.5, $rate->get_cost(), '[cost] should resolve to the total cost of the items.' );
+	}
+
+	/**
+	 * Shipping class costs are described as costs that "can optionally be added based on the
+	 * product shipping class", so they are added to the method's own cost.
+	 *
+	 * @testdox A shipping class cost is added to the method cost for items in that class.
+	 */
+	public function test_class_cost_is_added_for_items_in_that_class(): void {
+		$product = $this->shippable_product( 'flat-rate-heavy' );
+
+		$this->method_with(
+			array(
+				'cost' => '1',
+				'class_cost_' . $product->get_shipping_class_id() => '7',
+			)
+		);
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $product,
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+			),
+			10.0
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals( 8, $rate->get_cost(), 'The class cost should be added to the method cost.' );
+	}
+
+	/**
+	 * Class costs moved from slug keys to term id keys in 2.5.0, and both paths still read the
+	 * slug key so the old values keep working: the field seeds its default from it and
+	 * calculate_shipping() passes it as the fallback. Pinning the charged amount rather than
+	 * either path means this fails only when both are gone.
+	 *
+	 * @testdox A class cost saved under the pre-2.5.0 slug key is still charged.
+	 */
+	public function test_class_cost_saved_under_the_legacy_slug_key_is_still_charged(): void {
+		$product = $this->shippable_product( 'flat-rate-fragile' );
+
+		update_option( 'woocommerce_flat_rate_settings', array( 'class_cost_flat-rate-fragile' => '4' ) );
+
+		$this->method_with( array( 'cost' => '1' ) );
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $product,
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+			),
+			10.0
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals( 5, $rate->get_cost(), 'The slug-keyed class cost should still be honoured.' );
+	}
+
+	/**
+	 * The screen offers a "No shipping class cost" field, so items carrying no class are charged it.
+	 *
+	 * @testdox The no-class cost is charged for items that have no shipping class.
+	 */
+	public function test_no_class_cost_is_charged_for_unclassified_items(): void {
+		// A class has to exist somewhere in the store before any class costs are considered.
+		$this->shippable_product( 'flat-rate-bulky' );
+
+		$this->method_with(
+			array(
+				'cost'          => '1',
+				'no_class_cost' => '3',
+			)
+		);
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $this->shippable_product(),
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+			),
+			10.0
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals( 4, $rate->get_cost(), 'An item without a class should be charged the no-class cost.' );
+	}
+
+	/**
+	 * The two calculation types are described as "Charge shipping for each shipping class
+	 * individually" and "Charge shipping for the most expensive shipping class".
+	 *
+	 * @testdox Per class charges every class, per order charges only the most expensive one.
+	 *
+	 * @testWith ["class", 13]
+	 *           ["order", 9]
+	 *
+	 * @param string $type     The calculation type setting.
+	 * @param float  $expected Expected rate cost.
+	 */
+	public function test_calculation_type_decides_how_class_costs_combine( string $type, float $expected ): void {
+		$light = $this->shippable_product( 'flat-rate-light' );
+		$heavy = $this->shippable_product( 'flat-rate-oversized' );
+
+		$this->method_with(
+			array(
+				'cost' => '1',
+				'type' => $type,
+				'class_cost_' . $light->get_shipping_class_id() => '4',
+				'class_cost_' . $heavy->get_shipping_class_id() => '8',
+			)
+		);
+		// The dearer class is listed first, so simply taking the last class would not be mistaken
+		// for taking the most expensive one.
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $heavy,
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+				array(
+					'product'    => $light,
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+			),
+			20.0
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals( $expected, $rate->get_cost(), 'Per class should charge both 4 and 8, per order only the 8.' );
+	}
+
+	/**
+	 * The Cost field defaults to 0, so a shop that never touches it is offering free delivery
+	 * rather than no delivery.
+	 *
+	 * @testdox A cost of zero offers the shopper a rate priced zero.
+	 */
+	public function test_a_cost_of_zero_offers_a_free_rate(): void {
+		$this->method_with( array( 'cost' => '0' ) );
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $this->shippable_product(),
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+			),
+			10.0
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals( 0, $rate->get_cost(), 'A cost of zero should be a free rate, not no rate.' );
+	}
+
+	/**
+	 * calculate_shipping() only adds a rate once something sets a cost, so a method with nothing
+	 * filled in anywhere offers none at all rather than a free one.
+	 *
+	 * @testdox With no cost and no shipping classes in the store, the method offers no rate at all.
+	 */
+	public function test_a_method_with_nothing_filled_in_offers_no_rate(): void {
+		$this->method_with( array( 'cost' => '' ) );
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $this->shippable_product(),
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+			),
+			10.0
+		);
+
+		$this->assertSame( array(), $this->rates_for( $package ), 'A blank cost is not a free rate.' );
+	}
+
+	/**
+	 * Each class cost field shows "N/A" as its placeholder, so a class the merchant left blank
+	 * adds nothing rather than falling back to some other class's cost.
+	 *
+	 * @testdox A class left blank adds nothing, while the classes that were filled in still charge.
+	 */
+	public function test_a_blank_class_cost_adds_nothing(): void {
+		$charged = $this->shippable_product( 'flat-rate-charged' );
+		$blank   = $this->shippable_product( 'flat-rate-blank' );
+
+		$this->method_with(
+			array(
+				'cost' => '1',
+				'class_cost_' . $charged->get_shipping_class_id() => '6',
+				'class_cost_' . $blank->get_shipping_class_id() => '',
+			)
+		);
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $charged,
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+				array(
+					'product'    => $blank,
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+			),
+			20.0
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals( 7, $rate->get_cost(), 'The class carrying a cost should add it; the blank one should not turn into some other value.' );
+	}
+
+	/**
+	 * The no-class cost field is blank by default, so an unclassified item costs nothing extra
+	 * until the merchant says otherwise.
+	 *
+	 * @testdox With the no-class cost left blank an unclassified item adds nothing.
+	 */
+	public function test_a_blank_no_class_cost_adds_nothing(): void {
+		// A class has to exist somewhere in the store before any class costs are considered.
+		$this->shippable_product( 'flat-rate-somewhere' );
+
+		$this->method_with(
+			array(
+				'cost'          => '1',
+				'no_class_cost' => '',
+			)
+		);
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $this->shippable_product(),
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+			),
+			10.0
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals( 1, $rate->get_cost(), 'A blank no-class cost should not turn into some other value.' );
+	}
+
+	/**
+	 * find_shipping_classes() groups the package by class and each class cost is evaluated against
+	 * its own group, so the placeholders there count that class rather than the whole package.
+	 *
+	 * @testdox A placeholder in a class cost counts only the items in that class.
+	 *
+	 * @testWith ["[qty]", 3]
+	 *           ["[cost]", 30]
+	 *           ["[weight]", 6]
+	 *
+	 * @param string $class_cost Cost formula saved against the class.
+	 * @param float  $expected   Expected rate cost.
+	 */
+	public function test_a_class_cost_placeholder_counts_only_that_class( string $class_cost, float $expected ): void {
+		$scoped = $this->shippable_product( 'flat-rate-scoped', 2 );
+		$other  = $this->shippable_product( 'flat-rate-other', 4 );
+
+		$this->method_with(
+			array(
+				'cost' => '0',
+				'class_cost_' . $scoped->get_shipping_class_id() => $class_cost,
+				'class_cost_' . $other->get_shipping_class_id() => '',
+			)
+		);
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $scoped,
+					'quantity'   => 3,
+					'line_total' => 30.0,
+				),
+				array(
+					'product'    => $other,
+					'quantity'   => 1,
+					'line_total' => 70.0,
+				),
+			),
+			100.0
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals(
+			$expected,
+			$rate->get_cost(),
+			$class_cost . ' in a class cost should see only that class, not the whole package.'
+		);
+	}
+
+	/**
+	 * The method cost field documents [weight] alongside the other placeholders.
+	 *
+	 * @testdox The [weight] placeholder is the total weight of the package.
+	 */
+	public function test_weight_placeholder_is_the_total_weight_of_the_package(): void {
+		$this->method_with( array( 'cost' => '[weight]' ) );
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $this->shippable_product( '', 2 ),
+					'quantity'   => 3,
+					'line_total' => 30.0,
+				),
+			),
+			30.0
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals( 6, $rate->get_cost(), 'Three units weighing 2 each should give a weight of 6.' );
+	}
+
+	/**
+	 * find_shipping_classes() makes the same re-check, so a class sitting on an item that is not
+	 * shipped contributes nothing to the rate.
+	 *
+	 * @testdox A shipping class on an item that does not need shipping adds no cost.
+	 */
+	public function test_a_class_on_a_virtual_product_adds_nothing(): void {
+		$virtual = $this->shippable_product( 'flat-rate-downloadable', 0, true );
+
+		$this->method_with(
+			array(
+				'cost' => '1',
+				'class_cost_' . $virtual->get_shipping_class_id() => '9',
+			)
+		);
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $this->shippable_product(),
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+				array(
+					'product'    => $virtual,
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+			),
+			20.0
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals( 1, $rate->get_cost(), 'A class on something that is not shipped should not be charged for.' );
+	}
+
+	/**
+	 * The class cost fields and the no-class field are separate rows on the same form, and an
+	 * ordinary cart holds both kinds of item, so both charges land on the one rate.
+	 *
+	 * @testdox A cart holding a classified and an unclassified item is charged for both.
+	 */
+	public function test_a_mixed_package_is_charged_for_both_kinds_of_item(): void {
+		$classified = $this->shippable_product( 'flat-rate-mixed' );
+
+		$this->method_with(
+			array(
+				'cost'          => '1',
+				'class_cost_' . $classified->get_shipping_class_id() => '6',
+				'no_class_cost' => '3',
+			)
+		);
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $classified,
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+				array(
+					'product'    => $this->shippable_product(),
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+			),
+			20.0
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals( 10, $rate->get_cost(), 'The base cost, the class cost and the no-class cost should all be charged.' );
+	}
+
+	/**
+	 * A blank class cost adds nothing, so a method whose only field for this package is a blank
+	 * class cost has nothing to charge and offers no rate, the same as one with nothing filled in
+	 * at all. The sibling test keeps its rate through the base cost rather than through the class.
+	 *
+	 * @testdox A blank class cost and no other cost offers no rate.
+	 */
+	public function test_a_blank_class_cost_with_no_other_cost_offers_no_rate(): void {
+		$classified = $this->shippable_product( 'flat-rate-only-blank' );
+
+		$this->method_with(
+			array(
+				'cost' => '',
+				'class_cost_' . $classified->get_shipping_class_id() => '',
+			)
+		);
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $classified,
+					'quantity'   => 1,
+					'line_total' => 10.0,
+				),
+			),
+			10.0
+		);
+
+		$this->assertSame( array(), $this->rates_for( $package ), 'Nothing was filled in for this package, so there is nothing to charge.' );
+	}
+
+	/**
+	 * `[weight]` is substituted into the cost string before `do_shortcode()` runs, so it can stand
+	 * where a shortcode attribute is read rather than only where a number is.
+	 *
+	 * @testdox A percentage fee can take its percentage from the package weight.
+	 */
+	public function test_a_percentage_fee_can_be_driven_by_the_package_weight(): void {
+		$this->method_with( array( 'cost' => '[fee percent="[weight]"]' ) );
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $this->shippable_product( '', 10 ),
+					'quantity'   => 1,
+					'line_total' => 200.0,
+				),
+			),
+			200.0
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals( 20, $rate->get_cost(), 'Ten percent of a 200.00 package, with the ten coming from the weight.' );
+	}
+
+	/**
+	 * Nothing requires a product to carry a weight, so the percentage can come out as zero. The
+	 * method still offers the rate, priced at nothing, rather than withdrawing itself.
+	 *
+	 * @testdox A weight-driven percentage fee is free when nothing in the package has a weight.
+	 */
+	public function test_a_weight_driven_fee_is_free_without_a_weight(): void {
+		$this->method_with( array( 'cost' => '[fee percent="[weight]"]' ) );
+		$package = $this->package_of(
+			array(
+				array(
+					'product'    => $this->shippable_product(),
+					'quantity'   => 1,
+					'line_total' => 200.0,
+				),
+			),
+			200.0
+		);
+
+		$rate = $this->rate_for( $package );
+
+		$this->assertEquals( 0, $rate->get_cost(), 'No weight means no percentage, which is a free rate rather than no rate.' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-test.php b/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-test.php
index a71eb981e69..5aa0e1c3598 100644
--- a/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-test.php
+++ b/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-test.php
@@ -435,4 +435,28 @@ class WC_Shipping_Flat_Rate_Test extends WC_Unit_Test_Case {

 		$this->assertFloatEquals( 12.0, (float) $val, null, 'The fee should be a percentage of cost, with the weight added on top.' );
 	}
+
+	/**
+	 * A plain amount is stored dot-decimal whichever separator the merchant typed, so the value
+	 * that reaches evaluate_cost() is the one PHP can read. setUp() puts the store on a comma
+	 * decimal separator and a dot thousand separator, which is where the forms differ.
+	 *
+	 * The three rows are three different routes through the sanitiser: a value that is already
+	 * dot-decimal is taken as it stands, one written in the store's own separators is converted,
+	 * and one carrying a thousand separator has that removed rather than read as a decimal point.
+	 *
+	 * @testdox sanitize_cost() stores a plain amount dot-decimal whichever separator was typed.
+	 *
+	 * @testWith ["10.5", "10.5"]
+	 *           ["10,5", "10.5"]
+	 *           ["1.000,50", "1000.50"]
+	 *
+	 * @param string $typed    What the merchant entered.
+	 * @param string $expected What should be stored.
+	 */
+	public function test_sanitize_cost_stores_a_plain_amount_dot_decimal( string $typed, string $expected ): void {
+		$result = $this->call_sanitize_cost->call( $this->sut, $typed );
+
+		$this->assertSame( $expected, trim( $result ), 'Entered as "' . $typed . '" on a comma-decimal store.' );
+	}
 }