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.' );
+ }
}