Commit 31382445455 for woocommerce

commit 31382445455d8eb8107bbc615d1a6326ab64be03
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Thu Sep 3 23:16:37 2026 +0300

    [tests] Add REST tax class and tax rate lifecycle coverage (#68280)

    * test(rest-api): Add tax class and tax rate lifecycle coverage

    The Playwright api project creates tax classes and rates through wc/v3 and
    asserts the response echo. Nothing reads the rate back from the tax table,
    checks that a forced delete actually removed it, or exercises the batch
    endpoint.

    Add PHPUnit lifecycle coverage for tax classes, tax rates and the taxes batch
    endpoint that re-reads persisted rows after each write and asserts absence
    after delete. Additive; the Playwright specs are untouched.

    Extracted from the reference branch behind #68046, where each test was
    mutation-checked against its controller method.

    Refs #68046

    * test(rest-api): Assert tax rate delete clears location rows

    The V3 tax rate lifecycle test is the only one that attaches cities
    and postcodes to a rate, which makes it the right place to pin what
    a delete actually removes.

    WC_Tax::_delete_tax_rate() deletes the location rows first and the
    rate row second, but the test only read back the rate row and the
    404 from the REST GET. Dropping that first DELETE left every
    assertion passing while orphaned city and postcode rows accumulated
    in the tax rate locations table. That is the mirror of a mutant the
    suite already killed, which cleared the locations and left the rate
    row behind; this direction was uncovered.

    Count the remaining location rows for the deleted rate and require
    zero. WC_Tax exposes only writers for locations, so a direct table
    read is the available path, and the method declares global $wpdb at
    the top to match the existing test in this class. With the locations
    DELETE removed the test now fails on '0' against '2', the orphaned
    city and postcode rows.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(rest-api): Assert response status before reading its payload

    Both lifecycle tests pulled values out of get_data() before
    asserting the status that guarantees those values exist: the rate
    CRUD test read an id 18 lines before its 201 check, the batch test
    read the create and update collections one line before each 200.

    When such a request fails, get_data() returns the WP_Error payload
    instead, which has no id and no create or update key. phpunit.xml
    sets convertWarningsToExceptions, so the PHP 8 undefined array key
    warning is promoted to an error and the test dies on the read,
    hiding the assertion that would have named the real cause.

    Move each status assertion above the read it protects and drop the
    now duplicate 201 check in the rate CRUD test. Forcing a 403 on
    create turns 'Undefined array key "id"' into 'Failed asserting that
    403 is identical to 201'. The batch update read is included for the
    same reason, though it sits closer to its assertion.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/test-rest-api-taxes b/plugins/woocommerce/changelog/test-rest-api-taxes
new file mode 100644
index 00000000000..79551a20f42
--- /dev/null
+++ b/plugins/woocommerce/changelog/test-rest-api-taxes
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Add REST taxes controller tests covering tax class and tax rate CRUD and batch lifecycles against persisted state.
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-taxes-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-taxes-controller-tests.php
index 2d517b2e0ce..d6064cba5a7 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-taxes-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-taxes-controller-tests.php
@@ -18,6 +18,231 @@ class WC_REST_Taxes_Controller_Tests extends WC_REST_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox A tax class completes its V3 create, read, list, unsupported update, and delete lifecycle.
+	 */
+	public function test_tax_class_crud_lifecycle(): void {
+		wp_set_current_user( $this->user );
+
+		$class_name = 'Lifecycle Class';
+		$class_slug = sanitize_title( $class_name );
+		$expected   = array(
+			'name' => $class_name,
+			'slug' => $class_slug,
+		);
+
+		$response = $this->do_rest_request( 'taxes/classes', 'POST', array( 'name' => $class_name ) );
+		$this->assertSame( 201, $response->get_status() );
+		$this->assertSame( $expected, $response->get_data() );
+		$this->assertSame( $expected, WC_Tax::get_tax_class_by( 'slug', $class_slug ) );
+
+		$response = $this->do_rest_get_request( 'taxes/classes/' . $class_slug );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertCount( 1, $response->get_data() );
+		$this->assertSame( $expected, array_intersect_key( $response->get_data()[0], $expected ) );
+
+		$response = $this->do_rest_get_request( 'taxes/classes' );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertContains( $class_slug, wp_list_pluck( $response->get_data(), 'slug' ) );
+
+		$response = $this->do_rest_request( 'taxes/classes/' . $class_slug, 'PUT', array( 'name' => 'Unsupported update' ) );
+		$this->assertSame( 404, $response->get_status() );
+		$this->assertSame( 'rest_no_route', $response->get_data()['code'] );
+
+		$response = $this->do_rest_request( 'taxes/classes/' . $class_slug, 'DELETE', array( 'force' => true ) );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $class_slug, $response->get_data()['slug'] );
+		$this->assertFalse( WC_Tax::get_tax_class_by( 'slug', $class_slug ) );
+
+		$response = $this->do_rest_get_request( 'taxes/classes/' . $class_slug );
+		$this->assertSame( 404, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_tax_class_invalid_slug', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox A tax rate completes its V3 CRUD lifecycle and every read reflects the persisted rate.
+	 */
+	public function test_tax_rate_crud_lifecycle(): void {
+		global $wpdb;
+
+		wp_set_current_user( $this->user );
+
+		$class_slug = WC_Tax::create_tax_class( 'Lifecycle Rate Class' )['slug'];
+
+		$response = $this->do_rest_request(
+			'taxes',
+			'POST',
+			array(
+				'country'   => 'US',
+				'state'     => 'CA',
+				'cities'    => array( 'Los Angeles' ),
+				'postcodes' => array( '90001' ),
+				'rate'      => '4.25',
+				'name'      => 'State Tax',
+				'shipping'  => false,
+				'order'     => 3,
+				'class'     => $class_slug,
+			)
+		);
+		$this->assertSame( 201, $response->get_status() );
+		$data     = $response->get_data();
+		$rate_id  = $data['id'];
+		$expected = array(
+			'id'        => $rate_id,
+			'country'   => 'US',
+			'state'     => 'CA',
+			'postcode'  => '90001',
+			'city'      => 'LOS ANGELES',
+			'rate'      => '4.2500',
+			'name'      => 'State Tax',
+			'priority'  => 1,
+			'compound'  => false,
+			'shipping'  => false,
+			'order'     => 3,
+			'class'     => $class_slug,
+			'postcodes' => array( '90001' ),
+			'cities'    => array( 'LOS ANGELES' ),
+		);
+		$this->assertIsInt( $rate_id );
+		$this->assertSame( $expected, array_intersect_key( $data, $expected ) );
+
+		$response = $this->do_rest_get_request( 'taxes/' . $rate_id );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $expected, array_intersect_key( $response->get_data(), $expected ) );
+
+		$response = $this->do_rest_get_request( 'taxes' );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertContains( $rate_id, wp_list_pluck( $response->get_data(), 'id' ) );
+
+		$response = $this->do_rest_request(
+			'taxes/' . $rate_id,
+			'PUT',
+			array(
+				'rate'     => '5.5',
+				'name'     => 'Updated Tax',
+				'priority' => 2,
+				'compound' => true,
+				'shipping' => true,
+				'order'    => 8,
+			)
+		);
+		$expected = array(
+			'id'       => $rate_id,
+			'rate'     => '5.5000',
+			'name'     => 'Updated Tax',
+			'priority' => 2,
+			'compound' => true,
+			'shipping' => true,
+			'order'    => 8,
+			'class'    => $class_slug,
+		);
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $expected, array_intersect_key( $response->get_data(), $expected ) );
+
+		$response = $this->do_rest_get_request( 'taxes/' . $rate_id );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $expected, array_intersect_key( $response->get_data(), $expected ) );
+
+		$response = $this->do_rest_request( 'taxes/' . $rate_id, 'DELETE', array( 'force' => true ) );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $rate_id, $response->get_data()['id'] );
+		$this->assertNull( WC_Tax::_get_tax_rate( $rate_id ) );
+		$remaining_locations = $wpdb->get_var(
+			$wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_tax_rate_locations WHERE tax_rate_id = %d", $rate_id )
+		);
+		$this->assertSame( '0', $remaining_locations );
+
+		$response = $this->do_rest_get_request( 'taxes/' . $rate_id );
+		$this->assertSame( 404, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_invalid_id', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Two tax rates complete V3 batch create, update, and delete lifecycles in order.
+	 */
+	public function test_tax_rate_batch_lifecycle(): void {
+		wp_set_current_user( $this->user );
+
+		$class_slug = WC_Tax::create_tax_class( 'Batch Class' )['slug'];
+
+		$response = $this->do_rest_request(
+			'taxes/batch',
+			'POST',
+			array(
+				'create' => array(
+					array(
+						'country' => 'US',
+						'state'   => 'NY',
+						'rate'    => '4.1',
+						'name'    => 'Batch One',
+						'order'   => 1,
+						'class'   => $class_slug,
+					),
+					array(
+						'country' => 'CA',
+						'state'   => 'BC',
+						'rate'    => '6.2',
+						'name'    => 'Batch Two',
+						'order'   => 2,
+						'class'   => $class_slug,
+					),
+				),
+			)
+		);
+		$this->assertSame( 200, $response->get_status() );
+		$created  = $response->get_data()['create'];
+		$rate_ids = wp_list_pluck( $created, 'id' );
+		$this->assertCount( 2, array_unique( $rate_ids ) );
+		$this->assertContainsOnly( 'int', $rate_ids );
+		$this->assertSame( array( 'Batch One', 'Batch Two' ), wp_list_pluck( $created, 'name' ) );
+		$this->assertSame( array( '4.1000', '6.2000' ), wp_list_pluck( $created, 'rate' ) );
+		$this->assertSame( array( $class_slug, $class_slug ), wp_list_pluck( $created, 'class' ) );
+
+		$response = $this->do_rest_request(
+			'taxes/batch',
+			'POST',
+			array(
+				'update' => array(
+					array(
+						'id'    => $rate_ids[0],
+						'rate'  => '4.1111',
+						'name'  => 'Batch One Updated',
+						'order' => 7,
+					),
+					array(
+						'id'       => $rate_ids[1],
+						'rate'     => '6.2222',
+						'name'     => 'Batch Two Updated',
+						'priority' => 3,
+					),
+				),
+			)
+		);
+		$this->assertSame( 200, $response->get_status() );
+		$updated = $response->get_data()['update'];
+		$this->assertSame( $rate_ids, wp_list_pluck( $updated, 'id' ) );
+		$this->assertSame( array( '4.1111', '6.2222' ), wp_list_pluck( $updated, 'rate' ) );
+		$this->assertSame( array( 'Batch One Updated', 'Batch Two Updated' ), wp_list_pluck( $updated, 'name' ) );
+		$this->assertSame( 7, $updated[0]['order'] );
+		$this->assertSame( 3, $updated[1]['priority'] );
+
+		foreach ( $rate_ids as $index => $rate_id ) {
+			$response = $this->do_rest_get_request( 'taxes/' . $rate_id );
+			$this->assertSame( 200, $response->get_status() );
+			$this->assertSame( $updated[ $index ]['rate'], $response->get_data()['rate'] );
+			$this->assertSame( $updated[ $index ]['name'], $response->get_data()['name'] );
+		}
+
+		$response = $this->do_rest_request( 'taxes/batch', 'POST', array( 'delete' => $rate_ids ) );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $rate_ids, wp_list_pluck( $response->get_data()['delete'], 'id' ) );
+
+		foreach ( $rate_ids as $rate_id ) {
+			$this->assertNull( WC_Tax::_get_tax_rate( $rate_id ) );
+			$this->assertSame( 404, $this->do_rest_get_request( 'taxes/' . $rate_id )->get_status() );
+		}
+	}
+
 	/**
 	 * Data provider for test_can_create_and_update_tax_rates_with_multiple_cities_and_postcodes.
 	 *