@@ -1753,4 +1753,116 @@ public function test_zero_amount_response_persists_real_itemized_rates() {
17531753 // top-level rate. This is the core guard against the zeroing-out regression.
17541754 $ this ->assertEqualsWithDelta ( 6.975 , array_sum ( $ persisted ), 0.0001 , 'Existing tax rate must not be zeroed out by a $0 calculation. ' );
17551755 }
1756+
1757+ /**
1758+ * `normalize_city()` strips semicolons and collapses whitespace.
1759+ *
1760+ * `WC_Tax::_update_tax_rate_cities()` treats `;` as a multi-city separator,
1761+ * but `WC_Tax::find_rates()` treats it as a literal character. `normalize_city()`
1762+ * strips `;` (and collapses whitespace) so the round-trip stays symmetric.
1763+ *
1764+ * @see WOOTAX-19
1765+ *
1766+ * @dataProvider normalize_city_provider
1767+ *
1768+ * @param string $input Raw city value to normalize.
1769+ * @param string $expected Expected normalized output.
1770+ */
1771+ public function test_normalize_city_strips_semicolons_and_normalizes_whitespace ( $ input , $ expected ) {
1772+ $ reflection = new ReflectionMethod ( 'WC_Connect_TaxJar_Integration ' , 'normalize_city ' );
1773+ $ reflection ->setAccessible ( true );
1774+
1775+ $ this ->assertSame ( $ expected , $ reflection ->invoke ( null , $ input ) );
1776+ }
1777+
1778+ /**
1779+ * Data provider for `test_normalize_city_strips_semicolons_and_normalizes_whitespace`.
1780+ *
1781+ * @return array<string, array{0: mixed, 1: mixed}>
1782+ */
1783+ public function normalize_city_provider () {
1784+ return array (
1785+ 'no semicolon — unchanged ' => array ( 'New York ' , 'New York ' ),
1786+ 'simple semicolon between words ' => array ( 'Casse;Berry ' , 'Casse Berry ' ),
1787+ 'semicolon with following space ' => array ( 'Casse; Berry ' , 'Casse Berry ' ),
1788+ 'leading semicolon ' => array ( ';Casselberry ' , 'Casselberry ' ),
1789+ 'trailing semicolon ' => array ( 'Casselberry; ' , 'Casselberry ' ),
1790+ 'consecutive semicolons ' => array ( 'Casse;;Berry ' , 'Casse Berry ' ),
1791+ 'wrapped in whitespace ' => array ( ' Casselberry ' , 'Casselberry ' ),
1792+ 'tab and newline collapse to space ' => array ( "Casse; \t\nBerry " , 'Casse Berry ' ),
1793+ 'empty string ' => array ( '' , '' ),
1794+ 'multi-segment with mixed separators ' => array ( ' Casse; ;Berry ' , 'Casse Berry ' ),
1795+ 'null — returned unchanged ' => array ( null , null ),
1796+ 'false — returned unchanged ' => array ( false , false ),
1797+ );
1798+ }
1799+
1800+ /**
1801+ * `get_backend_address()` strips a semicolon from an admin order city.
1802+ *
1803+ * The admin "Recalculate" path builds its taxable address from `$_POST`, so a
1804+ * `;`-bearing city must be normalized there too — otherwise backend recalculations
1805+ * would reintroduce the stored/looked-up asymmetry the frontend path now avoids.
1806+ *
1807+ * @see WOOTAX-19
1808+ */
1809+ public function test_get_backend_address_normalizes_semicolon_city () {
1810+ $ _POST ['country ' ] = 'US ' ;
1811+ $ _POST ['state ' ] = 'FL ' ;
1812+ $ _POST ['postcode ' ] = '33033 ' ;
1813+ $ _POST ['city ' ] = 'Casse;Berry ' ;
1814+
1815+ try {
1816+ $ address = $ this ->invoke_protected_method ( 'get_backend_address ' );
1817+ } finally {
1818+ unset( $ _POST ['country ' ], $ _POST ['state ' ], $ _POST ['postcode ' ], $ _POST ['city ' ] );
1819+ }
1820+
1821+ $ this ->assertStringNotContainsString ( '; ' , $ address ['to_city ' ], 'Backend order city must not retain a semicolon — `_update_tax_rate_cities()` would split it. ' );
1822+ $ this ->assertSame ( 'CASSE BERRY ' , $ address ['to_city ' ] );
1823+ }
1824+
1825+ /**
1826+ * `create_or_update_tax_rate()` is idempotent across semicolon-bearing cities.
1827+ *
1828+ * Regression test for the unbounded `wp_woocommerce_tax_rates` growth:
1829+ * `create_or_update_tax_rate()` called twice with the same semicolon-bearing
1830+ * city must reuse the existing rate row instead of inserting a duplicate.
1831+ *
1832+ * @see WOOTAX-19
1833+ */
1834+ public function test_create_or_update_tax_rate_does_not_duplicate_rows_for_semicolon_city () {
1835+ global $ wpdb ;
1836+
1837+ $ location = array (
1838+ 'to_country ' => 'US ' ,
1839+ 'to_state ' => 'FL ' ,
1840+ 'to_zip ' => '33033 ' ,
1841+ 'to_city ' => 'Casse;Berry ' ,
1842+ 'from_state ' => 'FL ' ,
1843+ );
1844+
1845+ // Snapshot the row count BEFORE the first call so the test isn't sensitive
1846+ // to fixtures/seed data (test DB might already have rates from other tests).
1847+ $ rates_table = $ wpdb ->prefix . 'woocommerce_tax_rates ' ;
1848+ $ initial_rate_count = (int ) $ wpdb ->get_var ( "SELECT COUNT(*) FROM {$ rates_table }" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1849+
1850+ $ first_id = $ this ->integration ->create_or_update_tax_rate ( $ location , 0.07 , '' , 1 , 1 , 'Tax ' );
1851+ $ second_id = $ this ->integration ->create_or_update_tax_rate ( $ location , 0.07 , '' , 1 , 1 , 'Tax ' );
1852+
1853+ // Same row id on both calls — find_rates() matched the second time.
1854+ $ this ->assertSame ( (int ) $ first_id , (int ) $ second_id , 'Second create_or_update_tax_rate() inserted a new row instead of reusing the existing one — find_rates() city lookup is asymmetric with _update_tax_rate_cities() storage. ' );
1855+
1856+ // Exactly one new row added, not two.
1857+ $ final_rate_count = (int ) $ wpdb ->get_var ( "SELECT COUNT(*) FROM {$ rates_table }" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1858+ $ this ->assertSame ( $ initial_rate_count + 1 , $ final_rate_count , 'Expected exactly one new tax rate row after two create_or_update_tax_rate() calls with the same Casse;Berry city. ' );
1859+
1860+ // Stored city in the locations table should be normalized — no `;`.
1861+ $ locations_table = $ wpdb ->prefix . 'woocommerce_tax_rate_locations ' ;
1862+ $ stored_cities = $ wpdb ->get_col ( $ wpdb ->prepare ( "SELECT location_code FROM {$ locations_table } WHERE tax_rate_id = %d AND location_type = %s " , (int ) $ first_id , 'city ' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1863+ $ this ->assertNotEmpty ( $ stored_cities );
1864+ foreach ( $ stored_cities as $ city ) {
1865+ $ this ->assertStringNotContainsString ( '; ' , $ city , 'Tax rate city stored with a semicolon — `_update_tax_rate_cities()` will split it and break find_rates() on subsequent lookups. ' );
1866+ }
1867+ }
17561868}
0 commit comments