Commit 7a077b512c0 for woocommerce

commit 7a077b512c0781d95a3cce4c1d7314ec0a90c5b5
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Fri Sep 4 09:35:13 2026 +0300

    [tests] Add REST shipping, reports, webhooks and customers coverage (#68285)

    * test(rest-api): Add shipping, reports, webhooks and customers coverage

    The Playwright api project covers shipping zones and methods, the reports
    index and sales reports, webhooks and customers as response-echo checks.
    Nothing reads a zone, method, webhook or customer back after a write,
    exercises the batch endpoints, guards the default zone, or proves the
    report rows come from the orders that were created.

    Add PHPUnit coverage for those lifecycles and contracts, reading persisted
    state after each write. Additive; the Playwright specs are untouched.

    Extracted from the reference branch behind #68046, where the webhook tests
    were mutation-checked against their controller methods.

    Refs #68046

    * test(rest-api): Replace report teardown TRUNCATEs with DELETE FROM

    The sales report teardown emptied the four HPOS order tables with
    TRUNCATE. TRUNCATE is DDL, so it implicitly commits the transaction
    WP_UnitTestCase opens for each test, leaving the parent rollback that
    runs straight after it with nothing to undo.

    Only the HPOS test used to reach that teardown, and its own setup_cot()
    call had already committed the transaction through DDL. The new
    non-HPOS response contract test arrives with an intact transaction, so
    the teardown started leaking its fixtures: a probe on this base class
    showed the administrator created in setUp surviving into the next test.

    Empty the tables with DELETE FROM instead, as tests/README.md requires
    and as the stock notification tests already do. Rows the HPOS test
    committed are still removed, and rows written inside a live transaction
    are left to the rollback.

    Refs #68046

    * test(rest-api): Capture webhook pings with the inherited HTTP fixture

    The webhook tests registered their own pre_http_request interceptor and
    collected pings into a local array passed by reference. That listener
    sat next to one the class already had: WC_Unit_Test_Case extends
    WP_HTTP_TestCase, whose setUp records every outbound request in
    $this->http_requests and delegates the canned response to
    $this->http_responder.

    Answer the pings through $this->http_responder and read them back from
    $this->http_requests, which tests/README.md names as the interception
    layer to use, dropping the interceptor factory. The shared delivery URL
    prefix becomes a constant so the responder and the reader agree on what
    counts as a ping.

    Refs #68046

    * test(rest-api): Scope the report HPOS cleanup to the test that enables it

    Swapping the sales report teardown's TRUNCATEs for DELETE FROM stopped
    the implicit commit that was leaking the new non-HPOS test's fixtures,
    but it also stopped committing something the rest of the suite had come
    to depend on.

    setup_cot() enables HPOS inside the test transaction. The TRUNCATEs
    committed that option, and the restore that runs after them was rolled
    back, so every test after this class in the HPOS-off job was running
    with HPOS silently on. Two Analytics report tests only pass in that
    state, and removing the commit turned the HPOS-off CI job red.

    Run the cleanup only for the test that switches to HPOS. The new test
    never writes to those tables, so it no longer runs TRUNCATE at all and
    its transaction rolls back as it should, while the HPOS test keeps the
    behaviour it has on trunk. The leaked HPOS state and the two Analytics
    tests that rely on it are pre-existing and left for their own change.

    Refs #68046

    * test(rest-api): Cover the sales report refund total with a real refund

    The response contract test observes total_refunds on an empty day, where
    it is the integer zero it was initialised to. That reads as though the
    field is always an integer, which is what the inherited V1 schema
    declares, and the fixture cannot show otherwise.

    It is not. WC_Report_Sales_By_Date::get_report_data() accumulates the
    field with floatval(), so any real refund returns a float and diverges
    from the schema. Matching the schema is not the fix: casting would round
    money away and change a response type clients already consume.

    Add a refunded order and assert what is actually returned, alongside the
    sales total being net of the refund. The empty-day case now asserts the
    exact zero rather than implying a type it cannot establish. Replacing
    floatval() with intval() in the report fails the new assertion.

    Refs #68046

    * test(rest-api): Assert the refund amount rather than its serialized type

    The refund coverage asserted that total_refunds comes back as a float.
    That is what the endpoint returns, but it is not what the inherited V1
    schema declares, and writing the runtime type into the test states that
    the divergence is the contract.

    It is not settled which side is wrong. The schema declares an integer;
    the report accumulates the field with floatval() and subtracts it from
    total_sales, so it holds money and a decimal is the only sensible value.
    Reconciling the two means changing the published schema or the response,
    neither of which belongs in a test-only change.

    Assert the refunded amount instead and leave the type unstated, so the
    test still fails if the field stops reflecting the refund without taking
    a position the PR is not making. Replacing floatval() with intval() in
    the report still fails on the amount.

    Refs #68046

diff --git a/plugins/woocommerce/changelog/test-rest-api-shipping-reports-webhooks-customers b/plugins/woocommerce/changelog/test-rest-api-shipping-reports-webhooks-customers
new file mode 100644
index 00000000000..4b4e041bd24
--- /dev/null
+++ b/plugins/woocommerce/changelog/test-rest-api-shipping-reports-webhooks-customers
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Add REST shipping, reports, webhooks and customers tests covering lifecycles, default-zone guards and report response contracts against persisted state.
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/customers.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/customers.php
index 84b93396426..ceba9b7dd50 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/customers.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/customers.php
@@ -65,7 +65,7 @@ class Customers extends WC_REST_Unit_Test_Case {
 		$matching_customer_data = current(
 			array_filter(
 				$customers,
-				function( $customer ) use ( $customer_1 ) {
+				function ( $customer ) use ( $customer_1 ) {
 					return $customer['id'] === $customer_1->get_id();
 				}
 			)
@@ -146,7 +146,7 @@ class Customers extends WC_REST_Unit_Test_Case {
 		$matching_customer_data = current(
 			array_filter(
 				$customers,
-				function( $customer ) use ( $customer_3 ) {
+				function ( $customer ) use ( $customer_3 ) {
 					return $customer['id'] === $customer_3->get_id();
 				}
 			)
@@ -610,6 +610,248 @@ class Customers extends WC_REST_Unit_Test_Case {
 		$this->assertEquals( 3, count( $data ) );
 	}

+	/**
+	 * @testdox Customer routes expose each user's role, filter the collection to customers unless role=all, and allow updating an allow-listed non-customer user, email included.
+	 */
+	public function test_customer_roles_and_collection_contract(): void {
+		wp_set_current_user( 1 );
+
+		$roles    = array(
+			'administrator' => $this->factory->user->create( array( 'role' => 'administrator' ) ),
+			'subscriber'    => $this->factory->user->create( array( 'role' => 'subscriber' ) ),
+			'customer'      => $this->factory->user->create( array( 'role' => 'customer' ) ),
+		);
+		$user_ids = array_values( $roles );
+
+		foreach ( $roles as $expected_role => $user_id ) {
+			$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/customers/' . $user_id ) );
+			$this->assertSame( 200, $response->get_status() );
+			$this->assertSame( $expected_role, $response->get_data()['role'] );
+		}
+
+		$request = new WP_REST_Request( 'GET', '/wc/v3/customers' );
+		$request->set_query_params(
+			array(
+				'include'  => $user_ids,
+				'orderby'  => 'id',
+				'per_page' => count( $user_ids ),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( array( $roles['customer'] ), wp_list_pluck( $response->get_data(), 'id' ), 'The collection should default to the customer role.' );
+
+		$request->set_param( 'role', 'all' );
+		$response   = $this->server->dispatch( $request );
+		$actual_ids = wp_list_pluck( $response->get_data(), 'id' );
+		sort( $actual_ids );
+		sort( $user_ids );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $user_ids, $actual_ids, 'role=all should return every included user.' );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/customers/' . $roles['subscriber'] );
+		$request->set_body_params(
+			array(
+				'email'      => 'registered.subscriber@woo.local',
+				'first_name' => 'Registered Subscriber',
+				'billing'    => array( 'first_name' => 'Registered Subscriber' ),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'registered.subscriber@woo.local', $response->get_data()['email'] );
+		$this->assertSame( 'Registered Subscriber', $response->get_data()['first_name'] );
+		$this->assertSame( 'Registered Subscriber', $response->get_data()['billing']['first_name'] );
+
+		clean_user_cache( $roles['subscriber'] );
+		$fresh_customer = new WC_Customer( $roles['subscriber'] );
+		$this->assertSame( 'registered.subscriber@woo.local', $fresh_customer->get_email() );
+		$this->assertSame( 'Registered Subscriber', $fresh_customer->get_first_name() );
+		$this->assertSame( 'Registered Subscriber', $fresh_customer->get_billing_first_name() );
+	}
+
+	/**
+	 * @testdox A customer's full billing and shipping addresses round-trip through create, read, update, and delete.
+	 */
+	public function test_customer_crud_lifecycle_contract(): void {
+		wp_set_current_user( 1 );
+
+		$billing  = array(
+			'first_name' => 'Ada',
+			'last_name'  => 'Lovelace',
+			'company'    => 'Analytical Engines',
+			'address_1'  => '12 St James Square',
+			'address_2'  => 'Suite 3',
+			'city'       => 'London',
+			'state'      => 'London',
+			'postcode'   => 'SW1Y 4JH',
+			'country'    => 'GB',
+			'email'      => 'customer_crud_contract@woo.local',
+			'phone'      => '02079460000',
+		);
+		$shipping = array(
+			'first_name' => 'Ada',
+			'last_name'  => 'Lovelace',
+			'company'    => 'Analytical Engines',
+			'address_1'  => '15 Hanover Square',
+			'address_2'  => 'Floor 2',
+			'city'       => 'London',
+			'state'      => 'London',
+			'postcode'   => 'W1S 1HS',
+			'country'    => 'GB',
+			'phone'      => '02079460001',
+		);
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/customers' );
+		$request->set_body_params(
+			array(
+				'username'   => 'customer_crud_contract',
+				'password'   => 'test123',
+				'email'      => 'customer_crud_contract@woo.local',
+				'first_name' => 'Ada',
+				'last_name'  => 'Lovelace',
+				'billing'    => $billing,
+				'shipping'   => $shipping,
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 201, $response->get_status() );
+		$customer_id = $data['id'];
+		$this->assertIsInt( $customer_id );
+		$this->assertSame( 'customer', $data['role'] );
+		$this->assert_customer_address_matches( $billing, $data['billing'] );
+		$this->assert_customer_address_matches( $shipping, $data['shipping'] );
+
+		$fresh_customer = new WC_Customer( $customer_id );
+		$this->assertSame( $billing['address_1'], $fresh_customer->get_billing_address_1() );
+		$this->assertSame( $shipping['address_1'], $fresh_customer->get_shipping_address_1() );
+
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/customers/' . $customer_id ) );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assert_customer_address_matches( $billing, $response->get_data()['billing'] );
+		$this->assert_customer_address_matches( $shipping, $response->get_data()['shipping'] );
+
+		$billing['first_name']  = 'Augusta';
+		$billing['address_1']   = '18 St James Square';
+		$shipping['first_name'] = 'Augusta';
+		$shipping['address_1']  = '20 Hanover Square';
+		$request                = new WP_REST_Request( 'PUT', '/wc/v3/customers/' . $customer_id );
+		$request->set_body_params(
+			array(
+				'first_name' => 'Augusta',
+				'billing'    => $billing,
+				'shipping'   => $shipping,
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'Augusta', $response->get_data()['first_name'] );
+		$this->assert_customer_address_matches( $billing, $response->get_data()['billing'] );
+		$this->assert_customer_address_matches( $shipping, $response->get_data()['shipping'] );
+
+		clean_user_cache( $customer_id );
+		$fresh_customer = new WC_Customer( $customer_id );
+		$this->assertSame( 'Augusta', $fresh_customer->get_first_name() );
+		$this->assertSame( $billing['address_1'], $fresh_customer->get_billing_address_1() );
+		$this->assertSame( $shipping['address_1'], $fresh_customer->get_shipping_address_1() );
+
+		$request = new WP_REST_Request( 'DELETE', '/wc/v3/customers/' . $customer_id );
+		$request->set_param( 'force', true );
+		$this->assertSame( 200, $this->server->dispatch( $request )->get_status() );
+		$this->assertSame( 404, $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/customers/' . $customer_id ) )->get_status() );
+		$this->assertFalse( get_userdata( $customer_id ) );
+	}
+
+	/**
+	 * @testdox The customers batch route creates, updates, and deletes customers in request order with persisted results.
+	 */
+	public function test_customer_batch_lifecycle_contract(): void {
+		wp_set_current_user( 1 );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/customers/batch' );
+		$request->set_body_params(
+			array(
+				'create' => array(
+					array(
+						'username' => 'customer_batch_one',
+						'password' => 'test123',
+						'email'    => 'customer_batch_one@woo.local',
+						'billing'  => array( 'address_1' => '1 Compiler Way' ),
+					),
+					array(
+						'username' => 'customer_batch_two',
+						'password' => 'test123',
+						'email'    => 'customer_batch_two@woo.local',
+						'shipping' => array( 'address_1' => '3 Orbital Lane' ),
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( array( 'customer_batch_one', 'customer_batch_two' ), wp_list_pluck( $data['create'], 'username' ) );
+		$this->assertSame( '1 Compiler Way', $data['create'][0]['billing']['address_1'] );
+		$this->assertSame( '3 Orbital Lane', $data['create'][1]['shipping']['address_1'] );
+		$customer_ids = wp_list_pluck( $data['create'], 'id' );
+		$this->assertContainsOnly( 'int', $customer_ids );
+		$this->assertSame( '1 Compiler Way', ( new WC_Customer( $customer_ids[0] ) )->get_billing_address_1() );
+		$this->assertSame( '3 Orbital Lane', ( new WC_Customer( $customer_ids[1] ) )->get_shipping_address_1() );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/customers/batch' );
+		$request->set_body_params(
+			array(
+				'update' => array(
+					array(
+						'id'      => $customer_ids[0],
+						'billing' => array( 'address_1' => '11 Updated Way' ),
+					),
+					array(
+						'id'       => $customer_ids[1],
+						'shipping' => array( 'address_1' => '12 Updated Way' ),
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $customer_ids, wp_list_pluck( $data['update'], 'id' ) );
+		$this->assertSame( '11 Updated Way', $data['update'][0]['billing']['address_1'] );
+		$this->assertSame( '12 Updated Way', $data['update'][1]['shipping']['address_1'] );
+		clean_user_cache( $customer_ids[0] );
+		clean_user_cache( $customer_ids[1] );
+		$this->assertSame( '11 Updated Way', ( new WC_Customer( $customer_ids[0] ) )->get_billing_address_1() );
+		$this->assertSame( '12 Updated Way', ( new WC_Customer( $customer_ids[1] ) )->get_shipping_address_1() );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/customers/batch' );
+		$request->set_body_params( array( 'delete' => $customer_ids ) );
+		$response = $this->server->dispatch( $request );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $customer_ids, wp_list_pluck( $response->get_data()['delete'], 'id' ) );
+		foreach ( $customer_ids as $customer_id ) {
+			$this->assertSame( 404, $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/customers/' . $customer_id ) )->get_status() );
+			$this->assertFalse( get_userdata( $customer_id ) );
+		}
+	}
+
+	/**
+	 * Assert an exact customer address without treating associative key order as behavior.
+	 *
+	 * @param array $expected Expected address fields.
+	 * @param array $actual   Actual address fields.
+	 */
+	private function assert_customer_address_matches( array $expected, array $actual ): void {
+		ksort( $expected );
+		ksort( $actual );
+
+		$this->assertSame( $expected, $actual );
+	}
+
 	/**
 	 * Test customer schema.
 	 *
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-sales.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-sales.php
index 1085d5e724b..bcefde9c36a 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-sales.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-sales.php
@@ -27,6 +27,13 @@ class WC_Tests_API_Reports_Sales extends WC_REST_Unit_Test_Case {
 	 */
 	private $original_hpos_usage;

+	/**
+	 * Whether the running test switched the store over to HPOS.
+	 *
+	 * @var bool
+	 */
+	private $switched_to_hpos = false;
+
 	/**
 	 * Setup our test server, endpoints, and user info.
 	 */
@@ -47,12 +54,17 @@ class WC_Tests_API_Reports_Sales extends WC_REST_Unit_Test_Case {
 	public function tearDown(): void {
 		global $wpdb;

-		// phpcs:disable WordPress.DB.DirectDatabaseQuery
-		$wpdb->query( "TRUNCATE {$wpdb->prefix}wc_orders" );
-		$wpdb->query( "TRUNCATE {$wpdb->prefix}wc_orders_meta" );
-		$wpdb->query( "TRUNCATE {$wpdb->prefix}wc_order_operational_data" );
-		$wpdb->query( "TRUNCATE {$wpdb->prefix}wc_order_addresses" );
-		// phpcs:enable WordPress.DB.DirectDatabaseQuery
+		// Scoped to the test that switches to HPOS, the only one writing to these tables.
+		// A test that never switches keeps an intact transaction, and TRUNCATE is DDL
+		// whose implicit commit would leak its fixtures past the parent rollback.
+		if ( $this->switched_to_hpos ) {
+			// phpcs:disable WordPress.DB.DirectDatabaseQuery
+			$wpdb->query( "TRUNCATE {$wpdb->prefix}wc_orders" );
+			$wpdb->query( "TRUNCATE {$wpdb->prefix}wc_orders_meta" );
+			$wpdb->query( "TRUNCATE {$wpdb->prefix}wc_order_operational_data" );
+			$wpdb->query( "TRUNCATE {$wpdb->prefix}wc_order_addresses" );
+			// phpcs:enable WordPress.DB.DirectDatabaseQuery
+		}

 		remove_all_actions( 'pre_option_' . DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION );
 		OrderHelper::toggle_cot_feature_and_usage( $this->original_hpos_usage );
@@ -80,11 +92,124 @@ class WC_Tests_API_Reports_Sales extends WC_REST_Unit_Test_Case {
 		$property->setValue( array() );
 	}

+	/**
+	 * @testdox Should expose a stable V3 sales response shape for an empty day.
+	 */
+	public function test_sales_response_contract(): void {
+		wp_set_current_user( $this->user );
+
+		$date    = wp_date( 'Y-m-d' );
+		$request = new WP_REST_Request( 'GET', '/wc/v3/reports/sales' );
+		$request->set_query_params(
+			array(
+				'date_min' => $date,
+				'date_max' => $date,
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertCount( 1, $reports );
+
+		$report = $reports[0];
+		$this->assertSame(
+			array(
+				'total_sales',
+				'net_sales',
+				'average_sales',
+				'total_orders',
+				'total_items',
+				'total_tax',
+				'total_shipping',
+				'total_refunds',
+				'total_discount',
+				'totals_grouped_by',
+				'totals',
+				'total_customers',
+				'_links',
+			),
+			array_keys( $report )
+		);
+
+		foreach ( array( 'total_sales', 'net_sales', 'average_sales', 'total_tax', 'total_shipping', 'total_discount' ) as $key ) {
+			$this->assertIsString( $report[ $key ], $key . ' should be serialized as a decimal string.' );
+		}
+		foreach ( array( 'total_orders', 'total_items', 'total_customers' ) as $key ) {
+			$this->assertIsInt( $report[ $key ], $key . ' should be serialized as an integer.' );
+		}
+		// Kept out of the loop above because total_refunds is an integer only while it is
+		// zero. test_sales_refund_totals_contract() covers what a real refund returns.
+		$this->assertSame( 0, $report['total_refunds'] );
+
+		$this->assertSame( 'day', $report['totals_grouped_by'] );
+		$this->assertArrayHasKey( $date, $report['totals'] );
+
+		$period = $report['totals'][ $date ];
+		$this->assertSame( array( 'sales', 'orders', 'items', 'tax', 'shipping', 'discount', 'customers', 'refunds' ), array_keys( $period ) );
+		foreach ( array( 'sales', 'tax', 'shipping', 'discount', 'refunds' ) as $key ) {
+			$this->assertIsString( $period[ $key ], $key . ' should be serialized as a decimal string.' );
+		}
+		foreach ( array( 'orders', 'items', 'customers' ) as $key ) {
+			$this->assertIsInt( $period[ $key ], $key . ' should be serialized as an integer.' );
+		}
+	}
+
+	/**
+	 * @testdox A refund is subtracted from sales totals and comes back as a float, not the integer the schema declares.
+	 */
+	public function test_sales_refund_totals_contract(): void {
+		wp_set_current_user( $this->user );
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( '10.50' );
+		$product->save();
+
+		$order = wc_create_order();
+		$order->add_product( $product, 1 );
+		$order->calculate_totals();
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 3.25,
+			)
+		);
+
+		$this->clear_sales_report_cache();
+
+		$request = new WP_REST_Request( 'GET', '/wc/v3/reports/sales' );
+		$request->set_query_params(
+			array(
+				'date_min' => gmdate( 'Y-m-d', strtotime( '-1 day' ) ),
+				'date_max' => gmdate( 'Y-m-d', strtotime( '+1 day' ) ),
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$report   = $response->get_data()[0];
+
+		$this->assertSame( 200, $response->get_status() );
+
+		// The inherited V1 schema declares total_refunds an integer, but the report
+		// accumulates it with floatval() and subtracts it from total_sales, so a real
+		// refund reports a decimal amount. Assert the amount, not the serialized type:
+		// the schema and the implementation disagree about the type, and settling that
+		// is a production change this PR does not make.
+		$this->assertEquals( 3.25, $report['total_refunds'] );
+
+		$this->assertSame( wc_format_decimal( 7.25, 2 ), $report['total_sales'], 'Sales totals should be net of the refund.' );
+	}
+
 	/**
 	 * @testdox Should return sales totals from HPOS data when sync is disabled.
 	 */
 	public function test_get_sales_report_with_hpos_enabled_and_sync_off(): void {
 		$this->setup_cot();
+		$this->switched_to_hpos = true;
 		$this->disable_cot_sync();

 		wp_set_current_user( $this->user );
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-top-sellers.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-top-sellers.php
new file mode 100644
index 00000000000..33cf4fd1c4a
--- /dev/null
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-top-sellers.php
@@ -0,0 +1,84 @@
+<?php
+/**
+ * Tests for the reports top sellers REST API.
+ *
+ * @package WooCommerce\Tests\API
+ */
+
+declare( strict_types=1 );
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+
+/**
+ * WC_Tests_API_Reports_Top_Sellers.
+ */
+class WC_Tests_API_Reports_Top_Sellers extends WC_REST_Unit_Test_Case {
+
+	/**
+	 * Setup our test server and administrator.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->user = $this->factory->user->create(
+			array(
+				'role' => 'administrator',
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return top sellers in descending purchased-quantity order.
+	 */
+	public function test_top_sellers_contract(): void {
+		$high_seller = WC_Helper_Product::create_simple_product( true, array( 'name' => 'High quantity report product' ) );
+		$low_seller  = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Lower quantity report product' ) );
+
+		$order = wc_create_order();
+		$order->add_product( $high_seller, 3 );
+		$order->add_product( $low_seller, 1 );
+		$order->calculate_totals();
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		wp_set_current_user( $this->user );
+		$request = new WP_REST_Request( 'GET', '/wc/v3/reports/top_sellers' );
+		$request->set_query_params(
+			array(
+				'date_min' => gmdate( 'Y-m-d', strtotime( '-1 day' ) ),
+				'date_max' => gmdate( 'Y-m-d', strtotime( '+1 day' ) ),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame(
+			array(
+				array(
+					'name'       => 'High quantity report product',
+					'product_id' => $high_seller->get_id(),
+					'quantity'   => 3,
+				),
+				array(
+					'name'       => 'Lower quantity report product',
+					'product_id' => $low_seller->get_id(),
+					'quantity'   => 1,
+				),
+			),
+			array_map(
+				static function ( array $report ): array {
+					return array_intersect_key( $report, array_flip( array( 'name', 'product_id', 'quantity' ) ) );
+				},
+				$response->get_data()
+			)
+		);
+
+		$schema_response = $this->server->dispatch( new WP_REST_Request( 'OPTIONS', '/wc/v3/reports/top_sellers' ) );
+		$properties      = $schema_response->get_data()['schema']['properties'];
+		$this->assertSame( array( 'name', 'product_id', 'quantity' ), array_keys( $properties ) );
+		$this->assertSame( 'string', $properties['name']['type'] );
+		$this->assertSame( 'integer', $properties['product_id']['type'] );
+		$this->assertSame( 'integer', $properties['quantity']['type'] );
+
+		wp_set_current_user( 0 );
+		$this->assertSame( 401, $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/reports/top_sellers' ) )->get_status() );
+	}
+}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports.php
new file mode 100644
index 00000000000..946c08e95ef
--- /dev/null
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * Tests for the reports REST API.
+ *
+ * @package WooCommerce\Tests\API
+ */
+
+declare( strict_types=1 );
+
+/**
+ * WC_Tests_API_Reports.
+ */
+class WC_Tests_API_Reports extends WC_REST_Unit_Test_Case {
+
+	/**
+	 * Setup our test server and administrator.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->user = $this->factory->user->create(
+			array(
+				'role' => 'administrator',
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should expose the complete ordered V3 report registry through the registered route.
+	 */
+	public function test_reports_index_contract(): void {
+		wp_set_current_user( $this->user );
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/reports' ) );
+		$reports  = $response->get_data();
+
+		$expected_slugs = array(
+			'sales',
+			'top_sellers',
+			'orders/totals',
+			'products/totals',
+			'customers/totals',
+			'coupons/totals',
+			'reviews/totals',
+			'categories/totals',
+			'tags/totals',
+			'attributes/totals',
+		);
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $expected_slugs, wp_list_pluck( $reports, 'slug' ) );
+		$this->assertCount( count( $expected_slugs ), $reports );
+
+		foreach ( $reports as $report ) {
+			$this->assertIsString( $report['description'] );
+			$this->assertNotSame( '', $report['description'] );
+			$this->assertSame( rest_url( '/wc/v3/reports/' . $report['slug'] ), $report['_links']['self'][0]['href'] );
+			$this->assertSame( rest_url( 'wc/v3/reports' ), $report['_links']['collection'][0]['href'] );
+		}
+
+		$schema_response = $this->server->dispatch( new WP_REST_Request( 'OPTIONS', '/wc/v3/reports' ) );
+		$properties      = $schema_response->get_data()['schema']['properties'];
+
+		$this->assertSame( array( 'slug', 'description' ), array_keys( $properties ) );
+		$this->assertSame( 'string', $properties['slug']['type'] );
+		$this->assertSame( 'string', $properties['description']['type'] );
+
+		wp_set_current_user( 0 );
+		$anonymous_response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/reports' ) );
+
+		$this->assertSame( 401, $anonymous_response->get_status() );
+		$this->assertSame( 'woocommerce_rest_cannot_view', $anonymous_response->get_data()['code'] );
+	}
+}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-methods.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-methods.php
index 05d38ad0517..be608380f7d 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-methods.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-methods.php
@@ -35,6 +35,91 @@ class Shipping_Methods extends WC_REST_Unit_Test_Case {
 		$this->assertArrayHasKey( '/wc/v3/shipping_methods/(?P<id>[\w-]+)', $routes );
 	}

+	/**
+	 * @testdox Shipping method routes are read-only.
+	 */
+	public function test_read_only_route_contract(): void {
+		wp_set_current_user( $this->user );
+		$response = $this->server->dispatch( new WP_REST_Request( 'POST', '/wc/v3/shipping_methods' ) );
+		$this->assertSame( 404, $response->get_status() );
+		$this->assertSame( 'rest_no_route', $response->get_data()['code'] );
+	}
+
+	/**
+	 * Core shipping methods and optional instance costs.
+	 *
+	 * @return array
+	 */
+	public function core_shipping_method_provider(): array {
+		return array(
+			'flat rate'     => array( 'flat_rate', 'Flat rate', '10' ),
+			'free shipping' => array( 'free_shipping', 'Free shipping', null ),
+		);
+	}
+
+	/**
+	 * @testdox A core shipping method can be added to a zone with its settings, read back, and removed.
+	 *
+	 * @dataProvider core_shipping_method_provider
+	 *
+	 * @param string      $method_id    Shipping method ID.
+	 * @param string      $method_title Shipping method title.
+	 * @param string|null $cost         Optional instance cost.
+	 */
+	public function test_core_zone_method_create_lifecycle( $method_id, $method_title, $cost ): void {
+		wp_set_current_user( $this->user );
+
+		$zone = new WC_Shipping_Zone( null );
+		$zone->set_zone_name( 'Zone for ' . $method_title );
+		$zone->save();
+
+		$body = array(
+			'method_id' => $method_id,
+			'enabled'   => true,
+		);
+		if ( null !== $cost ) {
+			$body['settings'] = array( 'cost' => $cost );
+		}
+
+		$collection_path = '/wc/v3/shipping/zones/' . $zone->get_id() . '/methods';
+		$request         = new WP_REST_Request( 'POST', $collection_path );
+		$request->set_body_params( $body );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$instance_id = $data['instance_id'];
+		$this->assertIsInt( $instance_id );
+		$this->assertSame( $instance_id, $data['id'] );
+		$this->assertSame( $method_id, $data['method_id'] );
+		$this->assertSame( $method_title, $data['title'] );
+		$this->assertTrue( $data['enabled'] );
+		if ( null === $cost ) {
+			$this->assertArrayNotHasKey( 'cost', $data['settings'] );
+		} else {
+			$this->assertSame( $cost, $data['settings']['cost']['value'] );
+		}
+
+		$item_path = $collection_path . '/' . $instance_id;
+		$this->assertSame( rest_url( $item_path ), $data['_links']['self'][0]['href'] );
+		$this->assertSame( rest_url( $collection_path ), $data['_links']['collection'][0]['href'] );
+
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', $item_path ) );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $method_id, $response->get_data()['method_id'] );
+		$this->assertTrue( $response->get_data()['enabled'] );
+		if ( null !== $cost ) {
+			$this->assertSame( $cost, $response->get_data()['settings']['cost']['value'] );
+		}
+
+		$delete_request = new WP_REST_Request( 'DELETE', $item_path );
+		$delete_request->set_param( 'force', true );
+		$response = $this->server->dispatch( $delete_request );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $instance_id, $response->get_data()['instance_id'] );
+		$this->assertSame( 404, $this->server->dispatch( new WP_REST_Request( 'GET', $item_path ) )->get_status() );
+	}
+
 	/**
 	 * Test getting all shipping methods.
 	 *
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-zones.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-zones.php
index 9ce2bb54b20..bdcc6f9c5db 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-zones.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-zones.php
@@ -145,6 +145,42 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Test that the default Shipping Zone cannot be changed or deleted.
+	 *
+	 * @testdox Default shipping zone rejects mutation and survives force deletion.
+	 */
+	public function test_default_shipping_zone_guards() {
+		wp_set_current_user( $this->user );
+
+		$request = new WP_REST_Request( 'DELETE', '/wc/v3/shipping/zones/0' );
+		$request->set_param( 'force', true );
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame( 0, $response->get_data()['id'] );
+
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/shipping/zones' ) );
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame( array( 0 ), wp_list_pluck( $response->get_data(), 'id' ) );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/shipping/zones/0' );
+		$request->set_body_params( array( 'name' => 'Default shipping zone' ) );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+		$this->assertEquals( 403, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_shipping_zone_invalid_zone', $data['code'] );
+		$this->assertSame( 'The "locations not covered by your other zones" zone cannot be updated.', $data['message'] );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/shipping/zones/0/locations' );
+		$request->add_header( 'Content-Type', 'application/json' );
+		$request->set_body( wp_json_encode( array( array( 'code' => 'US' ) ) ) );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+		$this->assertEquals( 403, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_shipping_zone_locations_invalid_zone', $data['code'] );
+		$this->assertSame( 'The "locations not covered by your other zones" zone cannot be updated.', $data['message'] );
+	}
+
 	/**
 	 * Test /shipping/zones without valid permissions/creds.
 	 *
@@ -305,6 +341,10 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case {
 				$data
 			)
 		);
+
+		$persisted_zone = new WC_Shipping_Zone( $zone->get_id() );
+		$this->assertSame( 'Zone Test', $persisted_zone->get_zone_name() );
+		$this->assertSame( 2, $persisted_zone->get_zone_order() );
 	}

 	/**
@@ -342,6 +382,11 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case {
 		$data     = $response->get_data();

 		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame( $zone->get_id(), $data['id'] );
+
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/shipping/zones/' . $zone->get_id() ) );
+		$this->assertEquals( 404, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_shipping_zone_invalid', $response->get_data()['code'] );
 	}

 	/**
@@ -578,6 +623,66 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Test that location updates replace prior locations and can clear the zone.
+	 *
+	 * @testdox Shipping zone locations replace countries with a state and then clear.
+	 */
+	public function test_replace_and_clear_locations() {
+		wp_set_current_user( $this->user );
+		$zone  = $this->create_shipping_zone( 'Replacement Zone' );
+		$route = '/wc/v3/shipping/zones/' . $zone->get_id() . '/locations';
+
+		$request = new WP_REST_Request( 'PUT', $route );
+		$request->add_header( 'Content-Type', 'application/json' );
+		$request->set_body( wp_json_encode( array( array( 'code' => 'GB' ), array( 'code' => 'US' ) ) ) );
+		$response = $this->server->dispatch( $request );
+		$expected = array(
+			array(
+				'code' => 'GB',
+				'type' => 'country',
+			),
+			array(
+				'code' => 'US',
+				'type' => 'country',
+			),
+		);
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame( $expected, $this->get_location_tuples( $response->get_data() ) );
+		$this->assertSame( $expected, $this->get_location_tuples( ( new WC_Shipping_Zone( $zone->get_id() ) )->get_zone_locations() ) );
+
+		$request = new WP_REST_Request( 'PUT', $route );
+		$request->add_header( 'Content-Type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					array(
+						'code' => 'BR:SP',
+						'type' => 'state',
+					),
+				)
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$expected = array(
+			array(
+				'code' => 'BR:SP',
+				'type' => 'state',
+			),
+		);
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame( $expected, $this->get_location_tuples( $response->get_data() ) );
+		$this->assertSame( $expected, $this->get_location_tuples( ( new WC_Shipping_Zone( $zone->get_id() ) )->get_zone_locations() ) );
+
+		$request = new WP_REST_Request( 'PUT', $route );
+		$request->add_header( 'Content-Type', 'application/json' );
+		$request->set_body( wp_json_encode( array() ) );
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame( array(), $this->get_location_tuples( $response->get_data() ) );
+		$this->assertSame( array(), $this->get_location_tuples( ( new WC_Shipping_Zone( $zone->get_id() ) )->get_zone_locations() ) );
+	}
+
 	/**
 	 * Test updating Shipping Zone Locations with a bad zone ID.
 	 *
@@ -834,4 +939,22 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case {
 		$response = $this->server->dispatch( $request );
 		$this->assertEquals( 200, $response->get_status() );
 	}
+
+	/**
+	 * Reduce response arrays and persisted location objects to stable tuples.
+	 *
+	 * @param array $locations Shipping zone locations.
+	 * @return array
+	 */
+	private function get_location_tuples( $locations ) {
+		return array_map(
+			static function ( $location ) {
+				return array(
+					'code' => is_array( $location ) ? $location['code'] : $location->code,
+					'type' => is_array( $location ) ? $location['type'] : $location->type,
+				);
+			},
+			$locations
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-webhooks-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-webhooks-controller-tests.php
new file mode 100644
index 00000000000..ed468eea260
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-webhooks-controller-tests.php
@@ -0,0 +1,293 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Class WC_REST_Webhooks_Controller_Tests.
+ */
+class WC_REST_Webhooks_Controller_Tests extends WC_REST_Unit_Test_Case {
+
+	/**
+	 * The prefix every webhook delivery URL in this class shares.
+	 */
+	private const PING_URL_PREFIX = 'https://example.com/';
+
+	/**
+	 * Answer webhook pings through the HTTP fixture inherited from WP_HTTP_TestCase.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->http_responder = array( $this, 'respond_to_ping' );
+	}
+
+	/**
+	 * @testdox A webhook completes its registered V3 CRUD lifecycle and sends one create ping.
+	 */
+	public function test_webhook_crud_lifecycle(): void {
+		$delivery_url = self::PING_URL_PREFIX . 'crud-webhook';
+
+		wp_set_current_user( 1 );
+
+		$response = $this->do_rest_request(
+			'webhooks',
+			'POST',
+			array(
+				'name'         => 'order updates',
+				'topic'        => 'order.updated',
+				'delivery_url' => $delivery_url,
+			)
+		);
+		$data     = $response->get_data();
+
+		$this->assertSame( 201, $response->get_status() );
+		$webhook_id = $data['id'];
+		$this->assertIsInt( $webhook_id );
+		$this->assertSame( rest_url( 'wc/v3/webhooks/' . $webhook_id ), $response->get_headers()['Location'] );
+		$expected_webhook = array(
+			'id'           => $webhook_id,
+			'name'         => 'order updates',
+			'status'       => 'active',
+			'topic'        => 'order.updated',
+			'hooks'        => array(
+				'woocommerce_update_order',
+				'woocommerce_order_refunded',
+			),
+			'delivery_url' => $delivery_url,
+		);
+		$this->assertSame(
+			$expected_webhook,
+			array_intersect_key(
+				$data,
+				array_flip( array( 'id', 'name', 'status', 'topic', 'delivery_url', 'hooks' ) )
+			)
+		);
+		$create_links = $response->get_links();
+		$this->assertSame( rest_url( 'wc/v3/webhooks/' . $webhook_id ), $create_links['self'][0]['href'] );
+		$this->assertSame( rest_url( 'wc/v3/webhooks' ), $create_links['collection'][0]['href'] );
+
+		$fresh_webhook = wc_get_webhook( $webhook_id );
+		$this->assertInstanceOf( WC_Webhook::class, $fresh_webhook );
+		$this->assertSame( 'order updates', $fresh_webhook->get_name() );
+		$this->assertSame( 'active', $fresh_webhook->get_status() );
+		$this->assertSame( 'order.updated', $fresh_webhook->get_topic() );
+		$this->assertSame( $delivery_url, $fresh_webhook->get_delivery_url() );
+		$this->assert_ping_contract( $delivery_url, $webhook_id );
+
+		$response = $this->do_rest_get_request( 'webhooks/' . $webhook_id );
+		$this->assertSame( 200, $response->get_status() );
+		$item_data = $response->get_data();
+		$this->assertSame(
+			$expected_webhook,
+			array_intersect_key(
+				$item_data,
+				array_flip( array( 'id', 'name', 'status', 'topic', 'delivery_url', 'hooks' ) )
+			)
+		);
+		$item_links = $response->get_links();
+		$this->assertSame( rest_url( 'wc/v3/webhooks/' . $webhook_id ), $item_links['self'][0]['href'] );
+		$this->assertSame( rest_url( 'wc/v3/webhooks' ), $item_links['collection'][0]['href'] );
+
+		$response = $this->do_rest_get_request( 'webhooks', array( 'include' => array( $webhook_id ) ) );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( array( $webhook_id ), wp_list_pluck( $response->get_data(), 'id' ) );
+
+		$response = $this->do_rest_request(
+			'webhooks/' . $webhook_id,
+			'PUT',
+			array(
+				'name'   => 'paused order updates',
+				'status' => 'paused',
+			)
+		);
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $webhook_id, $response->get_data()['id'] );
+		$this->assertSame( 'paused order updates', $response->get_data()['name'] );
+		$this->assertSame( 'paused', $response->get_data()['status'] );
+
+		$fresh_webhook = wc_get_webhook( $webhook_id );
+		$this->assertInstanceOf( WC_Webhook::class, $fresh_webhook );
+		$this->assertSame( 'paused order updates', $fresh_webhook->get_name() );
+		$this->assertSame( 'paused', $fresh_webhook->get_status() );
+
+		$response = $this->do_rest_request( 'webhooks/' . $webhook_id, 'DELETE', array( 'force' => true ) );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $webhook_id, $response->get_data()['id'] );
+		$this->assertNull( wc_get_webhook( $webhook_id ) );
+		$this->assertSame( 404, $this->do_rest_get_request( 'webhooks/' . $webhook_id )->get_status() );
+	}
+
+	/**
+	 * @testdox Webhooks complete registered V3 batch create, mixed, and delete lifecycles.
+	 */
+	public function test_webhook_batch_lifecycle(): void {
+		$first_url  = self::PING_URL_PREFIX . 'batch-first';
+		$second_url = self::PING_URL_PREFIX . 'batch-second';
+		$third_url  = self::PING_URL_PREFIX . 'batch-third';
+
+		wp_set_current_user( 1 );
+
+		$response = $this->do_rest_request(
+			'webhooks/batch',
+			'POST',
+			array(
+				'create' => array(
+					array(
+						'name'         => 'coupon created',
+						'topic'        => 'coupon.created',
+						'delivery_url' => $first_url,
+					),
+					array(
+						'name'         => 'customer deleted',
+						'topic'        => 'customer.deleted',
+						'delivery_url' => $second_url,
+					),
+				),
+			)
+		);
+		$data     = $response->get_data();
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertCount( 2, $data['create'] );
+		$this->assertContainsOnly( 'int', wp_list_pluck( $data['create'], 'id' ) );
+		$first_id  = $data['create'][0]['id'];
+		$second_id = $data['create'][1]['id'];
+		$this->assertGreaterThan( 0, $first_id );
+		$this->assertGreaterThan( 0, $second_id );
+		$this->assertNotSame( $first_id, $second_id );
+		$this->assertSame( array( 'coupon created', 'customer deleted' ), wp_list_pluck( $data['create'], 'name' ) );
+		$this->assertSame( array( 'coupon.created', 'customer.deleted' ), wp_list_pluck( $data['create'], 'topic' ) );
+		$this->assertSame( array( $first_url, $second_url ), wp_list_pluck( $data['create'], 'delivery_url' ) );
+		$this->assertCount( 2, $this->captured_pings() );
+		$this->assert_ping_contract( $first_url, $first_id );
+		$this->assert_ping_contract( $second_url, $second_id );
+
+		$first_webhook  = wc_get_webhook( $first_id );
+		$second_webhook = wc_get_webhook( $second_id );
+		$this->assertInstanceOf( WC_Webhook::class, $first_webhook );
+		$this->assertInstanceOf( WC_Webhook::class, $second_webhook );
+		$this->assertSame( 'coupon created', $first_webhook->get_name() );
+		$this->assertSame( 'customer deleted', $second_webhook->get_name() );
+
+		$response = $this->do_rest_request(
+			'webhooks/batch',
+			'POST',
+			array(
+				'create' => array(
+					array(
+						'name'         => 'order created',
+						'topic'        => 'order.created',
+						'delivery_url' => $third_url,
+					),
+				),
+				'update' => array(
+					array(
+						'id'     => $first_id,
+						'name'   => 'paused coupon created',
+						'status' => 'paused',
+					),
+				),
+				'delete' => array( $second_id ),
+			)
+		);
+		$data     = $response->get_data();
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertCount( 1, $data['create'] );
+		$this->assertCount( 1, $data['update'] );
+		$this->assertCount( 1, $data['delete'] );
+		$third_id = $data['create'][0]['id'];
+		$this->assertGreaterThan( 0, $third_id );
+		$this->assertSame( 'order created', $data['create'][0]['name'] );
+		$this->assertSame( 'active', $data['create'][0]['status'] );
+		$this->assertSame( 'order.created', $data['create'][0]['topic'] );
+		$this->assertSame( $third_url, $data['create'][0]['delivery_url'] );
+		$this->assertSame( $first_id, $data['update'][0]['id'] );
+		$this->assertSame( 'paused coupon created', $data['update'][0]['name'] );
+		$this->assertSame( 'paused', $data['update'][0]['status'] );
+		$this->assertSame( $second_id, $data['delete'][0]['id'] );
+		$this->assertCount( 3, $this->captured_pings() );
+		$this->assert_ping_contract( $third_url, $third_id );
+
+		$first_webhook = wc_get_webhook( $first_id );
+		$third_webhook = wc_get_webhook( $third_id );
+		$this->assertInstanceOf( WC_Webhook::class, $first_webhook );
+		$this->assertInstanceOf( WC_Webhook::class, $third_webhook );
+		$this->assertSame( 'paused coupon created', $first_webhook->get_name() );
+		$this->assertSame( 'paused', $first_webhook->get_status() );
+		$this->assertSame( 'order created', $third_webhook->get_name() );
+		$this->assertSame( 'active', $third_webhook->get_status() );
+		$this->assertSame( 'order.created', $third_webhook->get_topic() );
+		$this->assertSame( $third_url, $third_webhook->get_delivery_url() );
+		$this->assertNull( wc_get_webhook( $second_id ) );
+		$this->assertSame( 404, $this->do_rest_get_request( 'webhooks/' . $second_id )->get_status() );
+
+		$response = $this->do_rest_request(
+			'webhooks/batch',
+			'POST',
+			array( 'delete' => array( $first_id, $third_id ) )
+		);
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( array( $first_id, $third_id ), wp_list_pluck( $response->get_data()['delete'], 'id' ) );
+		$this->assertNull( wc_get_webhook( $first_id ) );
+		$this->assertNull( wc_get_webhook( $third_id ) );
+		$this->assertSame( 404, $this->do_rest_get_request( 'webhooks/' . $first_id )->get_status() );
+		$this->assertSame( 404, $this->do_rest_get_request( 'webhooks/' . $third_id )->get_status() );
+	}
+
+	/**
+	 * Answer a webhook ping with a 200 so no delivery leaves the test.
+	 *
+	 * @param array  $request Request arguments.
+	 * @param string $url Request URL.
+	 * @return array|false
+	 */
+	public function respond_to_ping( $request, $url ) {
+		if ( 0 !== strpos( $url, self::PING_URL_PREFIX ) ) {
+			return false;
+		}
+
+		return array(
+			'headers'  => array(),
+			'body'     => '',
+			'response' => array(
+				'code'    => 200,
+				'message' => 'OK',
+			),
+			'cookies'  => array(),
+		);
+	}
+
+	/**
+	 * The webhook pings the inherited fixture captured.
+	 *
+	 * @return array
+	 */
+	private function captured_pings(): array {
+		return array_values(
+			array_filter(
+				$this->http_requests,
+				static function ( array $request ): bool {
+					return 0 === strpos( $request['url'], self::PING_URL_PREFIX );
+				}
+			)
+		);
+	}
+
+	/**
+	 * Assert one exact webhook ping contract.
+	 *
+	 * @param string $url Expected delivery URL.
+	 * @param int    $webhook_id Expected webhook ID.
+	 */
+	private function assert_ping_contract( string $url, int $webhook_id ): void {
+		$matches = array_values(
+			array_filter(
+				$this->captured_pings(),
+				static function ( array $ping ) use ( $url ): bool {
+					return $url === $ping['url'];
+				}
+			)
+		);
+
+		$this->assertCount( 1, $matches );
+		$this->assertSame( 'webhook_id=' . $webhook_id, $matches[0]['request']['body'] );
+	}
+}