Commit a9ebc98817c for woocommerce

commit a9ebc98817c964e8467c79c7739c5dd6fb936bec
Author: Alba Rincón <albarin@users.noreply.github.com>
Date:   Thu May 28 14:12:12 2026 +0200

    Add unit tests for the products and coupons GraphQL API (#65194)

    * Add unit tests for the products GraphQL API

    * Add unit tests for the coupons GraphQL API

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

    * Split wc-phpunit-graphql-api out of wc-phpunit-graphql-infra

    * Add GetProduct::authorize() tests for preauthorized + invalid ID

    * Tighten GraphQL API test assertions per review feedback

    * Enumerate Api/ test subdirs in phpunit.xml excludes

    * Add missing @param tag for non-positive id data provider test

    ---------

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

diff --git a/plugins/woocommerce/changelog/65194-64169-graphql-api-tests b/plugins/woocommerce/changelog/65194-64169-graphql-api-tests
new file mode 100644
index 00000000000..6ca84a5379f
--- /dev/null
+++ b/plugins/woocommerce/changelog/65194-64169-graphql-api-tests
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add unit test coverage for the products and coupons GraphQL API commands.
\ No newline at end of file
diff --git a/plugins/woocommerce/package.json b/plugins/woocommerce/package.json
index 36a59a1d718..194c79b4851 100644
--- a/plugins/woocommerce/package.json
+++ b/plugins/woocommerce/package.json
@@ -135,7 +135,8 @@
 					"shardingArguments": [
 						"--testsuite=wc-phpunit-legacy",
 						"--testsuite=wc-phpunit-main",
-						"--testsuite=wc-phpunit-graphql-infra"
+						"--testsuite=wc-phpunit-graphql-infra",
+						"--testsuite=wc-phpunit-graphql-api"
 					],
 					"onlyForDependencies": [],
 					"changes": [
@@ -166,7 +167,8 @@
 					"shardingArguments": [
 						"--testsuite=wc-phpunit-legacy",
 						"--testsuite=wc-phpunit-main",
-						"--testsuite=wc-phpunit-graphql-infra"
+						"--testsuite=wc-phpunit-graphql-infra",
+						"--testsuite=wc-phpunit-graphql-api"
 					],
 					"onlyForDependencies": [],
 					"changes": [
@@ -597,7 +599,8 @@
 					"shardingArguments": [
 						"--testsuite=wc-phpunit-legacy",
 						"--testsuite=wc-phpunit-main",
-						"--testsuite=wc-phpunit-graphql-infra"
+						"--testsuite=wc-phpunit-graphql-infra",
+						"--testsuite=wc-phpunit-graphql-api"
 					],
 					"onlyForDependencies": [],
 					"changes": [
diff --git a/plugins/woocommerce/phpunit.xml b/plugins/woocommerce/phpunit.xml
index 16a67826918..6b50659022a 100644
--- a/plugins/woocommerce/phpunit.xml
+++ b/plugins/woocommerce/phpunit.xml
@@ -11,11 +11,11 @@
 	xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
 	<testsuites>
 		<!-- Granular suites used by CI `shardingArguments` so the legacy,
-		     main and GraphQL-infrastructure tests run as separate parallel
-		     shards. The GraphQL infrastructure tests need PHP 8.1+ (the
-		     dummy fixture API uses enums, named arguments, etc.) and live
-		     in their own suite so PHP 7.4 / 8.0 CI jobs can leave them out
-		     of `shardingArguments`. -->
+		     main and GraphQL tests run as separate parallel shards. The
+		     two GraphQL suites need PHP 8.1+ (the dummy fixture API and
+		     the public command tree use enums, named arguments, etc.) and
+		     live in their own suites so PHP 7.4 / 8.0 CI jobs can leave
+		     them out of `shardingArguments`. -->
 		<testsuite name="wc-phpunit-legacy">
 			<directory suffix=".php">./tests/legacy/unit-tests</directory>
 		</testsuite>
@@ -23,13 +23,30 @@
 			<directory suffix=".php">./tests/php</directory>
 			<exclude>./tests/php/helpers</exclude>
 			<exclude>./tests/php/src/Blocks/BlockPatterns/patterns</exclude>
+			<!-- Enumerated rather than a blanket ./tests/php/src/Api, so a new
+			     ./tests/php/src/Api/<X>/ subdir surfaces in this suite instead
+			     of being silently dropped from every suite but wc-phpunit-full.
+			     A new GraphQL test subdir must be added to one of the
+			     wc-phpunit-graphql-* testsuites AND listed here. -->
 			<exclude>./tests/php/src/Api/Infrastructure</exclude>
+			<exclude>./tests/php/src/Api/Queries</exclude>
+			<exclude>./tests/php/src/Api/Mutations</exclude>
 			<exclude>./tests/php/src/Internal/Api</exclude>
 		</testsuite>
+		<!-- GraphQL infrastructure: the manually-maintained code under
+		     src/Internal/Api/ and src/Api/Infrastructure/, plus the
+		     resolver tree generated by ApiBuilder against the dummy
+		     fixture API. -->
 		<testsuite name="wc-phpunit-graphql-infra">
 			<directory suffix=".php">./tests/php/src/Api/Infrastructure</directory>
 			<directory suffix=".php">./tests/php/src/Internal/Api</directory>
 		</testsuite>
+		<!-- GraphQL public API: command classes under src/Api/Queries/
+		     and src/Api/Mutations/ (products, coupons, …). -->
+		<testsuite name="wc-phpunit-graphql-api">
+			<directory suffix=".php">./tests/php/src/Api/Queries</directory>
+			<directory suffix=".php">./tests/php/src/Api/Mutations</directory>
+		</testsuite>

 		<!-- Umbrella suites used for local invocation. PHPUnit does not
 		     support testsuite composition by reference, so the directory
@@ -41,6 +58,8 @@
 			<exclude>./tests/php/helpers</exclude>
 			<exclude>./tests/php/src/Blocks/BlockPatterns/patterns</exclude>
 			<exclude>./tests/php/src/Api/Infrastructure</exclude>
+			<exclude>./tests/php/src/Api/Queries</exclude>
+			<exclude>./tests/php/src/Api/Mutations</exclude>
 			<exclude>./tests/php/src/Internal/Api</exclude>
 		</testsuite>
 		<testsuite name="wc-phpunit-full">
diff --git a/plugins/woocommerce/tests/legacy/bootstrap.php b/plugins/woocommerce/tests/legacy/bootstrap.php
index 1f8a8495deb..c7f5f5bf562 100644
--- a/plugins/woocommerce/tests/legacy/bootstrap.php
+++ b/plugins/woocommerce/tests/legacy/bootstrap.php
@@ -180,24 +180,37 @@ class WC_Unit_Tests_Bootstrap {
 	}

 	/**
-	 * Echo a "Not running GraphQL infrastructure tests" message when the
-	 * current invocation does not include the `wc-phpunit-graphql-infra` suite,
-	 * mirroring the "Not running ajax tests" line printed by WP's own bootstrap
-	 * for the `ajax`, `ms-files` and `external-http` groups.
+	 * Echo "Not running GraphQL …" messages when the current invocation
+	 * does not include the `wc-phpunit-graphql-infra` and/or
+	 * `wc-phpunit-graphql-api` suites, mirroring the "Not running ajax
+	 * tests" line printed by WP's own bootstrap for the `ajax`, `ms-files`
+	 * and `external-http` groups.
 	 *
-	 * The GraphQL infrastructure tests live in their own suite because they
-	 * require PHP 8.1+ and are excluded from the default suite.
+	 * Both GraphQL suites live in their own suites because they require
+	 * PHP 8.1+ and are excluded from the default suite.
 	 */
 	private function maybe_announce_skipped_graphql_infra_tests() {
-		$argv = isset( $GLOBALS['argv'] ) && is_array( $GLOBALS['argv'] ) ? $GLOBALS['argv'] : array();
+		$argv          = isset( $GLOBALS['argv'] ) && is_array( $GLOBALS['argv'] ) ? $GLOBALS['argv'] : array();
+		$running_infra = false;
+		$running_api   = false;
 		foreach ( $argv as $arg ) {
-			if ( 'wc-phpunit-graphql-infra' === $arg || 'wc-phpunit-full' === $arg
-				|| '--testsuite=wc-phpunit-graphql-infra' === $arg || '--testsuite=wc-phpunit-full' === $arg ) {
+			if ( 'wc-phpunit-full' === $arg || '--testsuite=wc-phpunit-full' === $arg ) {
 				return;
 			}
+			if ( 'wc-phpunit-graphql-infra' === $arg || '--testsuite=wc-phpunit-graphql-infra' === $arg ) {
+				$running_infra = true;
+			}
+			if ( 'wc-phpunit-graphql-api' === $arg || '--testsuite=wc-phpunit-graphql-api' === $arg ) {
+				$running_api = true;
+			}
 		}

-		echo 'Not running GraphQL infrastructure tests. To execute these, use --testsuite=wc-phpunit-graphql-infra or wc-phpunit-full.' . PHP_EOL;
+		if ( ! $running_infra ) {
+			echo 'Not running GraphQL infrastructure tests. To execute these, use --testsuite=wc-phpunit-graphql-infra or wc-phpunit-full.' . PHP_EOL;
+		}
+		if ( ! $running_api ) {
+			echo 'Not running GraphQL API command tests. To execute these, use --testsuite=wc-phpunit-graphql-api or wc-phpunit-full.' . PHP_EOL;
+		}
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Api/Mutations/Coupons/CreateCouponTest.php b/plugins/woocommerce/tests/php/src/Api/Mutations/Coupons/CreateCouponTest.php
new file mode 100644
index 00000000000..1c53cc88c15
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Api/Mutations/Coupons/CreateCouponTest.php
@@ -0,0 +1,161 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Api\Mutations\Coupons;
+
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus;
+use Automattic\WooCommerce\Api\Enums\Coupons\DiscountType;
+use Automattic\WooCommerce\Api\InputTypes\Coupons\CreateCouponInput;
+use Automattic\WooCommerce\Api\Mutations\Coupons\CreateCoupon;
+use WC_Coupon;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for {@see CreateCoupon}.
+ */
+class CreateCouponTest extends WC_Unit_Test_Case {
+	/**
+	 * The system under test.
+	 *
+	 * @var CreateCoupon
+	 */
+	private CreateCoupon $sut;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new CreateCoupon();
+	}
+
+	/**
+	 * @testdox execute() creates a coupon with the given code and returns its DTO.
+	 */
+	public function test_execute_creates_coupon_with_required_fields(): void {
+		$input       = new CreateCouponInput();
+		$input->code = 'welcome-2026';
+
+		$result = $this->sut->execute( $input );
+
+		$this->assertIsObject( $result );
+		$this->assertSame( 'welcome-2026', $result->code );
+		$this->assertGreaterThan( 0, $result->id );
+
+		$wc_coupon = new WC_Coupon( $result->id );
+		$this->assertSame( 'welcome-2026', $wc_coupon->get_code() );
+	}
+
+	/**
+	 * @testdox execute() persists optional scalar fields when provided.
+	 */
+	public function test_execute_persists_optional_scalar_fields(): void {
+		$input                         = new CreateCouponInput();
+		$input->code                   = 'spring-sale';
+		$input->description            = 'Spring sale discount.';
+		$input->amount                 = 15.5;
+		$input->individual_use         = true;
+		$input->usage_limit            = 100;
+		$input->usage_limit_per_user   = 1;
+		$input->limit_usage_to_x_items = 3;
+		$input->free_shipping          = true;
+		$input->exclude_sale_items     = true;
+		$input->minimum_amount         = 20.0;
+		$input->maximum_amount         = 200.0;
+		$input->date_expires           = '2026-12-31T23:59:59+00:00';
+
+		$result = $this->sut->execute( $input );
+
+		$wc_coupon = new WC_Coupon( $result->id );
+		$this->assertSame( 'Spring sale discount.', $wc_coupon->get_description() );
+		$this->assertSame( '15.5', $wc_coupon->get_amount() );
+		$this->assertTrue( $wc_coupon->get_individual_use() );
+		$this->assertSame( 100, $wc_coupon->get_usage_limit() );
+		$this->assertSame( 1, $wc_coupon->get_usage_limit_per_user() );
+		$this->assertSame( 3, $wc_coupon->get_limit_usage_to_x_items() );
+		$this->assertTrue( $wc_coupon->get_free_shipping() );
+		$this->assertTrue( $wc_coupon->get_exclude_sale_items() );
+		$this->assertSame( '20', $wc_coupon->get_minimum_amount() );
+		$this->assertSame( '200', $wc_coupon->get_maximum_amount() );
+		$this->assertSame( '2026-12-31', $wc_coupon->get_date_expires()->format( 'Y-m-d' ) );
+	}
+
+	/**
+	 * @testdox execute() persists array fields (product/category IDs, email restrictions).
+	 */
+	public function test_execute_persists_array_fields(): void {
+		$input                              = new CreateCouponInput();
+		$input->code                        = 'array-coupon';
+		$input->product_ids                 = array( 10, 20 );
+		$input->excluded_product_ids        = array( 30 );
+		$input->product_categories          = array( 1 );
+		$input->excluded_product_categories = array( 2, 3 );
+		$input->email_restrictions          = array( 'foo@example.com', 'bar@example.com' );
+
+		$result = $this->sut->execute( $input );
+
+		$wc_coupon = new WC_Coupon( $result->id );
+		$this->assertSame( array( 10, 20 ), $wc_coupon->get_product_ids() );
+		$this->assertSame( array( 30 ), $wc_coupon->get_excluded_product_ids() );
+		$this->assertSame( array( 1 ), $wc_coupon->get_product_categories() );
+		$this->assertSame( array( 2, 3 ), $wc_coupon->get_excluded_product_categories() );
+		$this->assertSame( array( 'foo@example.com', 'bar@example.com' ), $wc_coupon->get_email_restrictions() );
+	}
+
+	/**
+	 * @testdox execute() applies the discount_type enum when provided.
+	 */
+	public function test_execute_applies_discount_type_enum(): void {
+		$input                = new CreateCouponInput();
+		$input->code          = 'percent-off';
+		$input->discount_type = DiscountType::Percent;
+
+		$result = $this->sut->execute( $input );
+
+		$wc_coupon = new WC_Coupon( $result->id );
+		$this->assertSame( 'percent', $wc_coupon->get_discount_type() );
+	}
+
+	/**
+	 * @testdox execute() skips set_discount_type() when discount_type is null.
+	 */
+	public function test_execute_skips_discount_type_when_provided_null(): void {
+		$input                = new CreateCouponInput();
+		$input->code          = 'null-discount-type';
+		$input->discount_type = null;
+
+		$result = $this->sut->execute( $input );
+
+		$wc_coupon = new WC_Coupon( $result->id );
+		$this->assertSame( 'fixed_cart', $wc_coupon->get_discount_type() );
+	}
+
+	/**
+	 * @testdox execute() applies the status enum when provided.
+	 */
+	public function test_execute_applies_status_enum(): void {
+		$input         = new CreateCouponInput();
+		$input->code   = 'draft-coupon';
+		$input->status = CouponStatus::Draft;
+
+		$result = $this->sut->execute( $input );
+
+		$wc_coupon = new WC_Coupon( $result->id );
+		$this->assertSame( 'draft', $wc_coupon->get_status() );
+	}
+
+	/**
+	 * @testdox execute() skips set_status() when status is null.
+	 */
+	public function test_execute_skips_status_when_provided_null(): void {
+		$input         = new CreateCouponInput();
+		$input->code   = 'null-status';
+		$input->status = null;
+
+		$result = $this->sut->execute( $input );
+
+		$wc_coupon = new WC_Coupon( $result->id );
+		$this->assertSame( 'publish', $wc_coupon->get_status() );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Api/Mutations/Coupons/DeleteCouponTest.php b/plugins/woocommerce/tests/php/src/Api/Mutations/Coupons/DeleteCouponTest.php
new file mode 100644
index 00000000000..32034a1e7a8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Api/Mutations/Coupons/DeleteCouponTest.php
@@ -0,0 +1,137 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Api\Mutations\Coupons;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Mutations\Coupons\DeleteCoupon;
+use Automattic\WooCommerce\Api\Types\Coupons\DeleteCouponResult;
+use WC_Helper_Coupon;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for {@see DeleteCoupon}.
+ */
+class DeleteCouponTest extends WC_Unit_Test_Case {
+	/**
+	 * The system under test.
+	 *
+	 * @var DeleteCoupon
+	 */
+	private DeleteCoupon $sut;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new DeleteCoupon();
+	}
+
+	/**
+	 * Tear down.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'woocommerce_pre_delete_data' );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox execute() throws NOT_FOUND when the coupon ID does not exist.
+	 */
+	public function test_execute_throws_not_found_for_missing_coupon(): void {
+		try {
+			$this->sut->execute( 999999 );
+			$this->fail( 'Expected ApiException was not thrown.' );
+		} catch ( ApiException $e ) {
+			$this->assertSame( 'Coupon not found.', $e->getMessage() );
+			$this->assertSame( 'NOT_FOUND', $e->getErrorCode() );
+			$this->assertSame( 404, $e->getStatusCode() );
+		}
+	}
+
+	/**
+	 * @testdox execute() trashes the coupon when force=false and returns DeleteCouponResult.
+	 */
+	public function test_execute_trashes_coupon_without_force(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'to-trash' );
+		$id     = $coupon->get_id();
+
+		$result = $this->sut->execute( $id, false );
+
+		$this->assertInstanceOf( DeleteCouponResult::class, $result );
+		$this->assertSame( $id, $result->id );
+		$this->assertTrue( $result->deleted );
+		$this->assertSame( 'trash', get_post_status( $id ) );
+	}
+
+	/**
+	 * @testdox execute() permanently deletes the coupon when force=true.
+	 */
+	public function test_execute_force_deletes_coupon(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'to-delete' );
+		$id     = $coupon->get_id();
+
+		$result = $this->sut->execute( $id, true );
+
+		$this->assertInstanceOf( DeleteCouponResult::class, $result );
+		$this->assertSame( $id, $result->id );
+		$this->assertTrue( $result->deleted );
+		$this->assertNull( get_post( $id ) );
+	}
+
+	/**
+	 * @testdox execute() defaults to non-force deletion (trash).
+	 */
+	public function test_execute_defaults_to_trash(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'default-trash' );
+		$id     = $coupon->get_id();
+
+		$result = $this->sut->execute( $id );
+
+		$this->assertInstanceOf( DeleteCouponResult::class, $result );
+		$this->assertSame( $id, $result->id );
+		$this->assertTrue( $result->deleted );
+		$this->assertSame( 'trash', get_post_status( $id ) );
+	}
+
+	/**
+	 * @testdox execute() returns deleted=false when woocommerce_pre_delete_data short-circuits to false.
+	 */
+	public function test_execute_returns_false_when_pre_delete_filter_returns_false(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'kept' );
+		$id     = $coupon->get_id();
+
+		add_filter( 'woocommerce_pre_delete_data', '__return_false' );
+
+		$result = $this->sut->execute( $id, true );
+
+		$this->assertInstanceOf( DeleteCouponResult::class, $result );
+		$this->assertSame( $id, $result->id );
+		$this->assertFalse( $result->deleted );
+	}
+
+	/**
+	 * @testdox execute() surfaces a WP_Error from woocommerce_pre_delete_data as an INTERNAL_ERROR ApiException.
+	 */
+	public function test_execute_translates_wp_error_to_api_exception(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'failing' );
+
+		add_filter(
+			'woocommerce_pre_delete_data',
+			static function () {
+				return new \WP_Error( 'wc_delete_failed', 'Coupon delete failed.' );
+			}
+		);
+
+		try {
+			$this->sut->execute( $coupon->get_id(), true );
+			$this->fail( 'Expected ApiException was not thrown.' );
+		} catch ( ApiException $e ) {
+			$this->assertSame( 'Coupon delete failed.', $e->getMessage() );
+			$this->assertSame( 'INTERNAL_ERROR', $e->getErrorCode() );
+			$this->assertSame( 500, $e->getStatusCode() );
+		}
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Api/Mutations/Coupons/UpdateCouponTest.php b/plugins/woocommerce/tests/php/src/Api/Mutations/Coupons/UpdateCouponTest.php
new file mode 100644
index 00000000000..05ea8168da7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Api/Mutations/Coupons/UpdateCouponTest.php
@@ -0,0 +1,201 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Api\Mutations\Coupons;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus;
+use Automattic\WooCommerce\Api\Enums\Coupons\DiscountType;
+use Automattic\WooCommerce\Api\InputTypes\Coupons\UpdateCouponInput;
+use Automattic\WooCommerce\Api\Mutations\Coupons\UpdateCoupon;
+use WC_Coupon;
+use WC_Helper_Coupon;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for {@see UpdateCoupon}.
+ */
+class UpdateCouponTest extends WC_Unit_Test_Case {
+	/**
+	 * The system under test.
+	 *
+	 * @var UpdateCoupon
+	 */
+	private UpdateCoupon $sut;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new UpdateCoupon();
+	}
+
+	/**
+	 * @testdox execute() throws NOT_FOUND when the coupon ID does not exist.
+	 */
+	public function test_execute_throws_not_found_for_missing_coupon(): void {
+		$input     = new UpdateCouponInput();
+		$input->id = 999999;
+
+		try {
+			$this->sut->execute( $input );
+			$this->fail( 'Expected ApiException was not thrown.' );
+		} catch ( ApiException $e ) {
+			$this->assertSame( 'Coupon not found.', $e->getMessage() );
+			$this->assertSame( 'NOT_FOUND', $e->getErrorCode() );
+			$this->assertSame( 404, $e->getStatusCode() );
+		}
+	}
+
+	/**
+	 * @testdox execute() updates only fields that were marked provided.
+	 */
+	public function test_execute_updates_only_provided_fields(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'original-code' );
+		$coupon->set_description( 'Original description.' );
+		$coupon->save();
+
+		$input              = new UpdateCouponInput();
+		$input->id          = $coupon->get_id();
+		$input->description = 'Updated description.';
+		$input->mark_provided( 'description' );
+
+		$this->sut->execute( $input );
+
+		$reloaded = new WC_Coupon( $coupon->get_id() );
+		$this->assertSame( 'Updated description.', $reloaded->get_description() );
+		$this->assertSame( 'original-code', $reloaded->get_code() );
+	}
+
+	/**
+	 * @testdox execute() does not touch fields that were not marked provided, even if set on the DTO.
+	 */
+	public function test_execute_ignores_fields_not_marked_provided(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'original-code' );
+
+		$input       = new UpdateCouponInput();
+		$input->id   = $coupon->get_id();
+		$input->code = 'should-not-apply';
+
+		$this->sut->execute( $input );
+
+		$reloaded = new WC_Coupon( $coupon->get_id() );
+		$this->assertSame( 'original-code', $reloaded->get_code() );
+	}
+
+	/**
+	 * @testdox execute() applies a non-null discount_type enum.
+	 */
+	public function test_execute_applies_discount_type_enum(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'fixed-cart-coupon' );
+
+		$input                = new UpdateCouponInput();
+		$input->id            = $coupon->get_id();
+		$input->discount_type = DiscountType::Percent;
+		$input->mark_provided( 'discount_type' );
+
+		$this->sut->execute( $input );
+
+		$reloaded = new WC_Coupon( $coupon->get_id() );
+		$this->assertSame( 'percent', $reloaded->get_discount_type() );
+	}
+
+	/**
+	 * @testdox execute() skips set_discount_type() when discount_type is provided as null.
+	 */
+	public function test_execute_skips_discount_type_when_provided_null(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'fixed-cart-coupon' );
+
+		$input                = new UpdateCouponInput();
+		$input->id            = $coupon->get_id();
+		$input->discount_type = null;
+		$input->mark_provided( 'discount_type' );
+
+		$this->sut->execute( $input );
+
+		$reloaded = new WC_Coupon( $coupon->get_id() );
+		$this->assertSame( 'fixed_cart', $reloaded->get_discount_type() );
+	}
+
+	/**
+	 * @testdox execute() applies a non-null status enum.
+	 */
+	public function test_execute_applies_status_enum(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'status-coupon' );
+
+		$input         = new UpdateCouponInput();
+		$input->id     = $coupon->get_id();
+		$input->status = CouponStatus::Draft;
+		$input->mark_provided( 'status' );
+
+		$this->sut->execute( $input );
+
+		$reloaded = new WC_Coupon( $coupon->get_id() );
+		$this->assertSame( 'draft', $reloaded->get_status() );
+	}
+
+	/**
+	 * @testdox execute() skips set_status() when status is provided as null.
+	 */
+	public function test_execute_skips_status_when_provided_null(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'status-coupon' );
+
+		$input         = new UpdateCouponInput();
+		$input->id     = $coupon->get_id();
+		$input->status = null;
+		$input->mark_provided( 'status' );
+
+		$this->sut->execute( $input );
+
+		$reloaded = new WC_Coupon( $coupon->get_id() );
+		$this->assertSame( 'publish', $reloaded->get_status() );
+	}
+
+	/**
+	 * @testdox execute() updates scalar amount fields when marked provided.
+	 */
+	public function test_execute_updates_amount(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'amount-coupon' );
+
+		$input         = new UpdateCouponInput();
+		$input->id     = $coupon->get_id();
+		$input->amount = 25.5;
+		$input->mark_provided( 'amount' );
+
+		$this->sut->execute( $input );
+
+		$reloaded = new WC_Coupon( $coupon->get_id() );
+		$this->assertSame( '25.5', $reloaded->get_amount() );
+	}
+
+	/**
+	 * @testdox execute() updates array fields when marked provided.
+	 */
+	public function test_execute_updates_array_fields(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'array-update-coupon' );
+
+		$input                              = new UpdateCouponInput();
+		$input->id                          = $coupon->get_id();
+		$input->product_ids                 = array( 100, 200 );
+		$input->excluded_product_ids        = array( 300 );
+		$input->product_categories          = array( 4 );
+		$input->excluded_product_categories = array( 5, 6 );
+		$input->email_restrictions          = array( 'a@example.com' );
+		$input->mark_provided( 'product_ids' );
+		$input->mark_provided( 'excluded_product_ids' );
+		$input->mark_provided( 'product_categories' );
+		$input->mark_provided( 'excluded_product_categories' );
+		$input->mark_provided( 'email_restrictions' );
+
+		$this->sut->execute( $input );
+
+		$reloaded = new WC_Coupon( $coupon->get_id() );
+		$this->assertSame( array( 100, 200 ), $reloaded->get_product_ids() );
+		$this->assertSame( array( 300 ), $reloaded->get_excluded_product_ids() );
+		$this->assertSame( array( 4 ), $reloaded->get_product_categories() );
+		$this->assertSame( array( 5, 6 ), $reloaded->get_excluded_product_categories() );
+		$this->assertSame( array( 'a@example.com' ), $reloaded->get_email_restrictions() );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Api/Mutations/Products/CreateProductTest.php b/plugins/woocommerce/tests/php/src/Api/Mutations/Products/CreateProductTest.php
new file mode 100644
index 00000000000..e32815f2e64
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Api/Mutations/Products/CreateProductTest.php
@@ -0,0 +1,157 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Api\Mutations\Products;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Enums\Products\ProductStatus;
+use Automattic\WooCommerce\Api\InputTypes\Products\CreateProductInput;
+use Automattic\WooCommerce\Api\InputTypes\Products\DimensionsInput;
+use Automattic\WooCommerce\Api\Mutations\Products\CreateProduct;
+use Automattic\WooCommerce\Api\Utils\Products\ProductRepository;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for {@see CreateProduct}.
+ */
+class CreateProductTest extends WC_Unit_Test_Case {
+	/**
+	 * The system under test.
+	 *
+	 * @var CreateProduct
+	 */
+	private CreateProduct $sut;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new CreateProduct();
+		$this->sut->init( new ProductRepository() );
+	}
+
+	/**
+	 * @testdox execute() creates a product with the given name and returns its DTO.
+	 */
+	public function test_execute_creates_product_with_required_fields(): void {
+		$input       = new CreateProductInput();
+		$input->name = 'Brand New Widget';
+
+		$result = $this->sut->execute( $input );
+
+		$this->assertIsObject( $result );
+		$this->assertSame( 'Brand New Widget', $result->name );
+		$this->assertGreaterThan( 0, $result->id );
+
+		$wc_product = wc_get_product( $result->id );
+		$this->assertInstanceOf( \WC_Product::class, $wc_product );
+		$this->assertSame( 'Brand New Widget', $wc_product->get_name() );
+	}
+
+	/**
+	 * @testdox execute() persists optional scalar fields when provided.
+	 */
+	public function test_execute_persists_optional_scalar_fields(): void {
+		$input                    = new CreateProductInput();
+		$input->name              = 'Detailed Widget';
+		$input->slug              = 'detailed-widget';
+		$input->sku               = 'SKU-DETAILED-001';
+		$input->description       = 'The long description.';
+		$input->short_description = 'Short blurb.';
+		$input->regular_price     = 19.99;
+		$input->sale_price        = 14.99;
+		$input->manage_stock      = true;
+		$input->stock_quantity    = 42;
+
+		$result = $this->sut->execute( $input );
+
+		$wc_product = wc_get_product( $result->id );
+		$this->assertSame( 'detailed-widget', $wc_product->get_slug() );
+		$this->assertSame( 'SKU-DETAILED-001', $wc_product->get_sku() );
+		$this->assertSame( 'The long description.', $wc_product->get_description() );
+		$this->assertSame( 'Short blurb.', $wc_product->get_short_description() );
+		$this->assertSame( '19.99', $wc_product->get_regular_price() );
+		$this->assertSame( '14.99', $wc_product->get_sale_price() );
+		$this->assertTrue( $wc_product->get_manage_stock() );
+		$this->assertSame( 42, $wc_product->get_stock_quantity() );
+	}
+
+	/**
+	 * @testdox execute() applies the status enum when provided.
+	 */
+	public function test_execute_applies_status_enum(): void {
+		$input         = new CreateProductInput();
+		$input->name   = 'Draft Widget';
+		$input->status = ProductStatus::Draft;
+
+		$result = $this->sut->execute( $input );
+
+		$wc_product = wc_get_product( $result->id );
+		$this->assertSame( 'draft', $wc_product->get_status() );
+	}
+
+	/**
+	 * @testdox execute() applies dimension fields when a DimensionsInput is provided.
+	 */
+	public function test_execute_applies_dimensions(): void {
+		$dimensions         = new DimensionsInput();
+		$dimensions->length = 10.5;
+		$dimensions->width  = 5.25;
+		$dimensions->height = 2.0;
+		$dimensions->weight = 1.5;
+
+		$input             = new CreateProductInput();
+		$input->name       = 'Boxed Widget';
+		$input->dimensions = $dimensions;
+
+		$result = $this->sut->execute( $input );
+
+		$wc_product = wc_get_product( $result->id );
+		$this->assertSame( '10.5', $wc_product->get_length() );
+		$this->assertSame( '5.25', $wc_product->get_width() );
+		$this->assertSame( '2', $wc_product->get_height() );
+		$this->assertSame( '1.5', $wc_product->get_weight() );
+	}
+
+	/**
+	 * @testdox execute() throws VALIDATION_ERROR when the product name is already taken.
+	 */
+	public function test_execute_rejects_duplicate_name(): void {
+		WC_Helper_Product::create_simple_product( true, array( 'name' => 'Duplicate Widget' ) );
+
+		$input       = new CreateProductInput();
+		$input->name = 'Duplicate Widget';
+
+		try {
+			$this->sut->execute( $input );
+			$this->fail( 'Expected ApiException was not thrown.' );
+		} catch ( ApiException $e ) {
+			$this->assertSame( 'A product with this name already exists.', $e->getMessage() );
+			$this->assertSame( 'VALIDATION_ERROR', $e->getErrorCode() );
+			$this->assertSame( 422, $e->getStatusCode() );
+			$this->assertArrayHasKey( 'field', $e->getExtensions() );
+			$this->assertSame( 'name', $e->getExtensions()['field'] );
+		}
+	}
+
+	/**
+	 * @testdox execute() allows reusing the name of a trashed product.
+	 */
+	public function test_execute_allows_reusing_trashed_product_name(): void {
+		$existing    = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Trashed Widget' ) );
+		$existing_id = $existing->get_id();
+		$existing->delete( false );
+
+		$input       = new CreateProductInput();
+		$input->name = 'Trashed Widget';
+
+		$result = $this->sut->execute( $input );
+
+		$this->assertIsObject( $result );
+		$this->assertSame( 'Trashed Widget', $result->name );
+		$this->assertNotSame( $existing_id, $result->id );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Api/Mutations/Products/DeleteProductTest.php b/plugins/woocommerce/tests/php/src/Api/Mutations/Products/DeleteProductTest.php
new file mode 100644
index 00000000000..e09720e34c4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Api/Mutations/Products/DeleteProductTest.php
@@ -0,0 +1,122 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Api\Mutations\Products;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Mutations\Products\DeleteProduct;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for {@see DeleteProduct}.
+ */
+class DeleteProductTest extends WC_Unit_Test_Case {
+	/**
+	 * The system under test.
+	 *
+	 * @var DeleteProduct
+	 */
+	private DeleteProduct $sut;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new DeleteProduct();
+	}
+
+	/**
+	 * Tear down.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'woocommerce_pre_delete_product' );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox execute() throws NOT_FOUND when the product ID does not exist.
+	 */
+	public function test_execute_throws_not_found_for_missing_product(): void {
+		try {
+			$this->sut->execute( 999999 );
+			$this->fail( 'Expected ApiException was not thrown.' );
+		} catch ( ApiException $e ) {
+			$this->assertSame( 'Product not found.', $e->getMessage() );
+			$this->assertSame( 'NOT_FOUND', $e->getErrorCode() );
+			$this->assertSame( 404, $e->getStatusCode() );
+		}
+	}
+
+	/**
+	 * @testdox execute() trashes the product when force=false.
+	 */
+	public function test_execute_trashes_product_without_force(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$deleted = $this->sut->execute( $product->get_id(), false );
+
+		$this->assertTrue( $deleted );
+		$this->assertSame( 'trash', get_post_status( $product->get_id() ) );
+	}
+
+	/**
+	 * @testdox execute() permanently deletes the product when force=true.
+	 */
+	public function test_execute_force_deletes_product(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$id      = $product->get_id();
+
+		$deleted = $this->sut->execute( $id, true );
+
+		$this->assertTrue( $deleted );
+		$this->assertNull( get_post( $id ) );
+	}
+
+	/**
+	 * @testdox execute() defaults to non-force deletion (trash).
+	 */
+	public function test_execute_defaults_to_trash(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$this->sut->execute( $product->get_id() );
+
+		$this->assertSame( 'trash', get_post_status( $product->get_id() ) );
+	}
+
+	/**
+	 * @testdox execute() returns false when woocommerce_pre_delete_product short-circuits to false.
+	 */
+	public function test_execute_returns_false_when_pre_delete_filter_returns_false(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		add_filter( 'woocommerce_pre_delete_product', '__return_false' );
+
+		$this->assertFalse( $this->sut->execute( $product->get_id(), true ) );
+	}
+
+	/**
+	 * @testdox execute() surfaces a WP_Error from woocommerce_pre_delete_product as an INTERNAL_ERROR ApiException.
+	 */
+	public function test_execute_translates_wp_error_to_api_exception(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		add_filter(
+			'woocommerce_pre_delete_product',
+			static function () {
+				return new \WP_Error( 'wc_delete_failed', 'Something went wrong.' );
+			}
+		);
+
+		try {
+			$this->sut->execute( $product->get_id(), true );
+			$this->fail( 'Expected ApiException was not thrown.' );
+		} catch ( ApiException $e ) {
+			$this->assertSame( 'Something went wrong.', $e->getMessage() );
+			$this->assertSame( 'INTERNAL_ERROR', $e->getErrorCode() );
+			$this->assertSame( 500, $e->getStatusCode() );
+		}
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Api/Mutations/Products/UpdateProductTest.php b/plugins/woocommerce/tests/php/src/Api/Mutations/Products/UpdateProductTest.php
new file mode 100644
index 00000000000..1490d5c4c9a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Api/Mutations/Products/UpdateProductTest.php
@@ -0,0 +1,166 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Api\Mutations\Products;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Enums\Products\ProductStatus;
+use Automattic\WooCommerce\Api\InputTypes\Products\DimensionsInput;
+use Automattic\WooCommerce\Api\InputTypes\Products\UpdateProductInput;
+use Automattic\WooCommerce\Api\Mutations\Products\UpdateProduct;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for {@see UpdateProduct}.
+ */
+class UpdateProductTest extends WC_Unit_Test_Case {
+	/**
+	 * The system under test.
+	 *
+	 * @var UpdateProduct
+	 */
+	private UpdateProduct $sut;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new UpdateProduct();
+	}
+
+	/**
+	 * @testdox execute() throws NOT_FOUND when the product ID does not exist.
+	 */
+	public function test_execute_throws_not_found_for_missing_product(): void {
+		$input     = new UpdateProductInput();
+		$input->id = 999999;
+
+		try {
+			$this->sut->execute( $input );
+			$this->fail( 'Expected ApiException was not thrown.' );
+		} catch ( ApiException $e ) {
+			$this->assertSame( 'Product not found.', $e->getMessage() );
+			$this->assertSame( 'NOT_FOUND', $e->getErrorCode() );
+			$this->assertSame( 404, $e->getStatusCode() );
+		}
+	}
+
+	/**
+	 * @testdox execute() updates only fields that were marked provided.
+	 */
+	public function test_execute_updates_only_provided_fields(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'        => 'Original Name',
+				'description' => 'Original description.',
+			)
+		);
+
+		$input       = new UpdateProductInput();
+		$input->id   = $product->get_id();
+		$input->name = 'Updated Name';
+		$input->mark_provided( 'name' );
+
+		$this->sut->execute( $input );
+
+		$reloaded = wc_get_product( $product->get_id() );
+		$this->assertSame( 'Updated Name', $reloaded->get_name() );
+		$this->assertSame( 'Original description.', $reloaded->get_description() );
+	}
+
+	/**
+	 * @testdox execute() does not touch fields that were not marked provided, even if set on the DTO.
+	 */
+	public function test_execute_ignores_fields_not_marked_provided(): void {
+		$product = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Original Name' ) );
+
+		$input       = new UpdateProductInput();
+		$input->id   = $product->get_id();
+		$input->name = 'Should Not Apply';
+
+		$this->sut->execute( $input );
+
+		$reloaded = wc_get_product( $product->get_id() );
+		$this->assertSame( 'Original Name', $reloaded->get_name() );
+	}
+
+	/**
+	 * @testdox execute() skips set_status() when status is provided as null.
+	 */
+	public function test_execute_skips_status_when_provided_null(): void {
+		$product = WC_Helper_Product::create_simple_product( true, array( 'status' => 'draft' ) );
+
+		$input         = new UpdateProductInput();
+		$input->id     = $product->get_id();
+		$input->status = null;
+		$input->mark_provided( 'status' );
+
+		$this->sut->execute( $input );
+
+		$reloaded = wc_get_product( $product->get_id() );
+		$this->assertSame( 'draft', $reloaded->get_status() );
+	}
+
+	/**
+	 * @testdox execute() applies a non-null status enum.
+	 */
+	public function test_execute_applies_status_enum(): void {
+		$product = WC_Helper_Product::create_simple_product( true, array( 'status' => 'draft' ) );
+
+		$input         = new UpdateProductInput();
+		$input->id     = $product->get_id();
+		$input->status = ProductStatus::Published;
+		$input->mark_provided( 'status' );
+
+		$this->sut->execute( $input );
+
+		$reloaded = wc_get_product( $product->get_id() );
+		$this->assertSame( 'publish', $reloaded->get_status() );
+	}
+
+	/**
+	 * @testdox execute() clears a price when explicit null is provided.
+	 */
+	public function test_execute_clears_price_when_explicit_null(): void {
+		$product = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => '19.99' ) );
+
+		$input                = new UpdateProductInput();
+		$input->id            = $product->get_id();
+		$input->regular_price = null;
+		$input->mark_provided( 'regular_price' );
+
+		$this->sut->execute( $input );
+
+		$reloaded = wc_get_product( $product->get_id() );
+		$this->assertSame( '', $reloaded->get_regular_price() );
+	}
+
+	/**
+	 * @testdox execute() applies provided dimension fields and leaves others alone.
+	 */
+	public function test_execute_applies_dimensions_selectively(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_length( '10' );
+		$product->set_width( '5' );
+		$product->save();
+
+		$dimensions         = new DimensionsInput();
+		$dimensions->length = 20.0;
+		$dimensions->mark_provided( 'length' );
+
+		$input             = new UpdateProductInput();
+		$input->id         = $product->get_id();
+		$input->dimensions = $dimensions;
+		$input->mark_provided( 'dimensions' );
+
+		$this->sut->execute( $input );
+
+		$reloaded = wc_get_product( $product->get_id() );
+		$this->assertSame( '20', $reloaded->get_length() );
+		$this->assertSame( '5', $reloaded->get_width() );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Api/Queries/Coupons/GetCouponTest.php b/plugins/woocommerce/tests/php/src/Api/Queries/Coupons/GetCouponTest.php
new file mode 100644
index 00000000000..9d7c4f0d4fe
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Api/Queries/Coupons/GetCouponTest.php
@@ -0,0 +1,89 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Api\Queries\Coupons;
+
+use Automattic\WooCommerce\Api\Queries\Coupons\GetCoupon;
+use WC_Helper_Coupon;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for {@see GetCoupon}.
+ */
+class GetCouponTest extends WC_Unit_Test_Case {
+	/**
+	 * The system under test.
+	 *
+	 * @var GetCoupon
+	 */
+	private GetCoupon $sut;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new GetCoupon();
+	}
+
+	/**
+	 * @testdox execute() throws InvalidArgumentException when neither id nor code is provided.
+	 */
+	public function test_execute_rejects_missing_arguments(): void {
+		$this->expectException( \InvalidArgumentException::class );
+		$this->expectExceptionMessage( 'Exactly one of "id" or "code" must be provided.' );
+
+		$this->sut->execute();
+	}
+
+	/**
+	 * @testdox execute() throws InvalidArgumentException when both id and code are provided.
+	 */
+	public function test_execute_rejects_both_arguments(): void {
+		$this->expectException( \InvalidArgumentException::class );
+		$this->expectExceptionMessage( 'Exactly one of "id" or "code" must be provided.' );
+
+		$this->sut->execute( id: 1, code: 'something' );
+	}
+
+	/**
+	 * @testdox execute() returns the mapped Coupon DTO for a valid ID.
+	 */
+	public function test_execute_returns_coupon_for_valid_id(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'happy-friday' );
+
+		$result = $this->sut->execute( id: $coupon->get_id() );
+
+		$this->assertIsObject( $result );
+		$this->assertSame( $coupon->get_id(), $result->id );
+		$this->assertSame( 'happy-friday', $result->code );
+	}
+
+	/**
+	 * @testdox execute() returns the mapped Coupon DTO for a valid code.
+	 */
+	public function test_execute_returns_coupon_for_valid_code(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'spring-sale' );
+
+		$result = $this->sut->execute( code: 'spring-sale' );
+
+		$this->assertIsObject( $result );
+		$this->assertSame( $coupon->get_id(), $result->id );
+		$this->assertSame( 'spring-sale', $result->code );
+	}
+
+	/**
+	 * @testdox execute() returns null when the ID does not exist.
+	 */
+	public function test_execute_returns_null_for_missing_id(): void {
+		$this->assertNull( $this->sut->execute( id: 999999 ) );
+	}
+
+	/**
+	 * @testdox execute() returns null when the code does not exist.
+	 */
+	public function test_execute_returns_null_for_missing_code(): void {
+		$this->assertNull( $this->sut->execute( code: 'does-not-exist' ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Api/Queries/Coupons/ListCouponsTest.php b/plugins/woocommerce/tests/php/src/Api/Queries/Coupons/ListCouponsTest.php
new file mode 100644
index 00000000000..b35e44f45c8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Api/Queries/Coupons/ListCouponsTest.php
@@ -0,0 +1,230 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Api\Queries\Coupons;
+
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus;
+use Automattic\WooCommerce\Api\Pagination\IdCursorFilter;
+use Automattic\WooCommerce\Api\Pagination\PaginationParams;
+use Automattic\WooCommerce\Api\Queries\Coupons\ListCoupons;
+use ReflectionClass;
+use WC_Helper_Coupon;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for {@see ListCoupons}.
+ */
+class ListCouponsTest extends WC_Unit_Test_Case {
+	/**
+	 * The system under test.
+	 *
+	 * @var ListCoupons
+	 */
+	private ListCoupons $sut;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		// IdCursorFilter registers its posts_where hook once per request and
+		// remembers it on a static flag. WP_UnitTestCase resets $wp_filter on
+		// every tear_down(), so the actual hook is gone but the flag stays
+		// true — leaving subsequent cursor tests without a working filter.
+		$reflection = new ReflectionClass( IdCursorFilter::class );
+		$property   = $reflection->getProperty( 'registered' );
+		$property->setAccessible( true );
+		$property->setValue( null, false );
+
+		$this->sut = new ListCoupons();
+	}
+
+	/**
+	 * @testdox execute() returns all coupons with ascending IDs by default.
+	 */
+	public function test_execute_returns_all_coupons_in_ascending_order(): void {
+		$a = WC_Helper_Coupon::create_coupon( 'a-coupon' );
+		$b = WC_Helper_Coupon::create_coupon( 'b-coupon' );
+		$c = WC_Helper_Coupon::create_coupon( 'c-coupon' );
+
+		$connection = $this->sut->execute( new PaginationParams() );
+
+		$this->assertSame( 3, $connection->total_count );
+		$this->assertCount( 3, $connection->nodes );
+		$this->assertSame( $a->get_id(), $connection->nodes[0]->id );
+		$this->assertSame( $b->get_id(), $connection->nodes[1]->id );
+		$this->assertSame( $c->get_id(), $connection->nodes[2]->id );
+	}
+
+	/**
+	 * @testdox execute() honors `first` and signals has_next_page when more remain.
+	 */
+	public function test_execute_paginates_forward_with_first(): void {
+		WC_Helper_Coupon::create_coupon( 'a' );
+		WC_Helper_Coupon::create_coupon( 'b' );
+		WC_Helper_Coupon::create_coupon( 'c' );
+
+		$connection = $this->sut->execute( new PaginationParams( first: 2 ) );
+
+		$this->assertCount( 2, $connection->nodes );
+		$this->assertSame( 3, $connection->total_count );
+		$this->assertTrue( $connection->page_info->has_next_page );
+		$this->assertFalse( $connection->page_info->has_previous_page );
+	}
+
+	/**
+	 * @testdox execute() honors `after` and returns coupons with IDs > cursor.
+	 */
+	public function test_execute_paginates_forward_with_after_cursor(): void {
+		$first = WC_Helper_Coupon::create_coupon( 'a' );
+		$mid   = WC_Helper_Coupon::create_coupon( 'b' );
+		$last  = WC_Helper_Coupon::create_coupon( 'c' );
+
+		$after_cursor = base64_encode( (string) $first->get_id() );
+
+		$connection = $this->sut->execute(
+			new PaginationParams( first: 10, after: $after_cursor )
+		);
+
+		$this->assertCount( 2, $connection->nodes );
+		$this->assertSame( $mid->get_id(), $connection->nodes[0]->id );
+		$this->assertSame( $last->get_id(), $connection->nodes[1]->id );
+		$this->assertTrue( $connection->page_info->has_previous_page );
+		$this->assertFalse( $connection->page_info->has_next_page );
+	}
+
+	/**
+	 * @testdox execute() honors `last` and returns the trailing page in ascending order.
+	 */
+	public function test_execute_paginates_backward_with_last(): void {
+		WC_Helper_Coupon::create_coupon( 'a' );
+		$b = WC_Helper_Coupon::create_coupon( 'b' );
+		$c = WC_Helper_Coupon::create_coupon( 'c' );
+
+		$connection = $this->sut->execute( new PaginationParams( last: 2 ) );
+
+		$this->assertCount( 2, $connection->nodes );
+		$this->assertSame( $b->get_id(), $connection->nodes[0]->id );
+		$this->assertSame( $c->get_id(), $connection->nodes[1]->id );
+		$this->assertTrue( $connection->page_info->has_previous_page );
+		$this->assertFalse( $connection->page_info->has_next_page );
+	}
+
+	/**
+	 * @testdox execute() honors `before` and reports has_next_page=true (more remain after the window).
+	 */
+	public function test_execute_paginates_backward_with_before_cursor(): void {
+		$a = WC_Helper_Coupon::create_coupon( 'a' );
+		$b = WC_Helper_Coupon::create_coupon( 'b' );
+		$c = WC_Helper_Coupon::create_coupon( 'c' );
+
+		$before_cursor = base64_encode( (string) $c->get_id() );
+
+		$connection = $this->sut->execute(
+			new PaginationParams( last: 10, before: $before_cursor )
+		);
+
+		$this->assertCount( 2, $connection->nodes );
+		$this->assertSame( $a->get_id(), $connection->nodes[0]->id );
+		$this->assertSame( $b->get_id(), $connection->nodes[1]->id );
+		$this->assertTrue( $connection->page_info->has_next_page );
+	}
+
+	/**
+	 * @testdox execute() filters by status.
+	 */
+	public function test_execute_filters_by_status(): void {
+		$published = WC_Helper_Coupon::create_coupon( 'published-coupon' );
+		$draft_id  = wp_insert_post(
+			array(
+				'post_title'  => 'draft-coupon',
+				'post_type'   => 'shop_coupon',
+				'post_status' => 'draft',
+			)
+		);
+
+		$connection = $this->sut->execute( new PaginationParams(), CouponStatus::Draft );
+
+		$this->assertSame( 1, $connection->total_count );
+		$this->assertCount( 1, $connection->nodes );
+		$this->assertSame( $draft_id, $connection->nodes[0]->id );
+		$this->assertNotSame( $published->get_id(), $connection->nodes[0]->id );
+	}
+
+	/**
+	 * @testdox execute() reports total_count after filters, not the unfiltered total.
+	 */
+	public function test_total_count_reflects_filters(): void {
+		WC_Helper_Coupon::create_coupon( 'a' );
+		WC_Helper_Coupon::create_coupon( 'b' );
+		wp_insert_post(
+			array(
+				'post_title'  => 'draft',
+				'post_type'   => 'shop_coupon',
+				'post_status' => 'draft',
+			)
+		);
+
+		$connection = $this->sut->execute( new PaginationParams( first: 1 ), CouponStatus::Published );
+
+		$this->assertSame( 2, $connection->total_count );
+		$this->assertCount( 1, $connection->nodes );
+	}
+
+	/**
+	 * @testdox each edge carries a base64-encoded ID cursor.
+	 */
+	public function test_edges_carry_base64_id_cursors(): void {
+		$coupon = WC_Helper_Coupon::create_coupon( 'a' );
+
+		$connection = $this->sut->execute( new PaginationParams() );
+
+		$this->assertCount( 1, $connection->edges );
+		$this->assertSame( base64_encode( (string) $coupon->get_id() ), $connection->edges[0]->cursor );
+		$this->assertSame( $coupon->get_id(), $connection->edges[0]->node->id );
+	}
+
+	/**
+	 * @testdox start_cursor and end_cursor on page_info mirror the first and last edge cursors.
+	 */
+	public function test_page_info_carries_start_and_end_cursors(): void {
+		$first = WC_Helper_Coupon::create_coupon( 'a' );
+		WC_Helper_Coupon::create_coupon( 'b' );
+		$last = WC_Helper_Coupon::create_coupon( 'c' );
+
+		$connection = $this->sut->execute( new PaginationParams() );
+
+		$this->assertSame( base64_encode( (string) $first->get_id() ), $connection->page_info->start_cursor );
+		$this->assertSame( base64_encode( (string) $last->get_id() ), $connection->page_info->end_cursor );
+	}
+
+	/**
+	 * @testdox an empty result set returns no edges and null start/end cursors.
+	 */
+	public function test_empty_result_set(): void {
+		$connection = $this->sut->execute( new PaginationParams() );
+
+		$this->assertSame( 0, $connection->total_count );
+		$this->assertSame( array(), $connection->edges );
+		$this->assertSame( array(), $connection->nodes );
+		$this->assertNull( $connection->page_info->start_cursor );
+		$this->assertNull( $connection->page_info->end_cursor );
+		$this->assertFalse( $connection->page_info->has_next_page );
+		$this->assertFalse( $connection->page_info->has_previous_page );
+	}
+
+	/**
+	 * @testdox first=N with exactly N matching coupons reports has_next_page=false.
+	 */
+	public function test_first_equal_to_total_reports_no_next_page(): void {
+		WC_Helper_Coupon::create_coupon( 'a' );
+		WC_Helper_Coupon::create_coupon( 'b' );
+
+		$connection = $this->sut->execute( new PaginationParams( first: 2 ) );
+
+		$this->assertCount( 2, $connection->nodes );
+		$this->assertSame( 2, $connection->total_count );
+		$this->assertFalse( $connection->page_info->has_next_page );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Api/Queries/Products/GetProductTest.php b/plugins/woocommerce/tests/php/src/Api/Queries/Products/GetProductTest.php
new file mode 100644
index 00000000000..3c19cdad379
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Api/Queries/Products/GetProductTest.php
@@ -0,0 +1,224 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Api\Queries\Products;
+
+use Automattic\WooCommerce\Api\Queries\Products\GetProduct;
+use Automattic\WooCommerce\Api\UnauthorizedException;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for {@see GetProduct}.
+ */
+class GetProductTest extends WC_Unit_Test_Case {
+	/**
+	 * The system under test.
+	 *
+	 * @var GetProduct
+	 */
+	private GetProduct $sut;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		wp_set_current_user( 0 );
+		$this->sut = new GetProduct();
+	}
+
+	/**
+	 * Tear down.
+	 */
+	public function tearDown(): void {
+		wp_set_current_user( 0 );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox authorize() returns true for an admin reading any product.
+	 */
+	public function test_authorize_allows_admin_for_any_product(): void {
+		$author  = self::factory()->user->create( array( 'role' => 'shop_manager' ) );
+		$product = WC_Helper_Product::create_simple_product();
+		wp_update_post(
+			array(
+				'ID'          => $product->get_id(),
+				'post_author' => $author,
+			)
+		);
+
+		$admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin );
+
+		$this->assertTrue( $this->sut->authorize( $product->get_id(), false ) );
+	}
+
+	/**
+	 * @testdox authorize() returns true when the caller owns the product.
+	 */
+	public function test_authorize_allows_owner(): void {
+		$user    = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+		$product = WC_Helper_Product::create_simple_product();
+		wp_update_post(
+			array(
+				'ID'          => $product->get_id(),
+				'post_author' => $user,
+			)
+		);
+		wp_set_current_user( $user );
+
+		$this->assertTrue( $this->sut->authorize( $product->get_id(), false ) );
+	}
+
+	/**
+	 * @testdox authorize() returns true when _preauthorized is true and the product exists.
+	 */
+	public function test_authorize_honors_preauthorized_flag(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$this->assertTrue( $this->sut->authorize( $product->get_id(), true ) );
+	}
+
+	/**
+	 * @testdox authorize() still throws for a non-existent ID even when _preauthorized is true.
+	 */
+	public function test_authorize_rejects_missing_product_even_when_preauthorized(): void {
+		$this->expectException( UnauthorizedException::class );
+		$this->expectExceptionMessage( 'Product not found.' );
+
+		$this->sut->authorize( 999999, true );
+	}
+
+	/**
+	 * @testdox authorize() still throws for a non-product post even when _preauthorized is true.
+	 */
+	public function test_authorize_rejects_non_product_post_even_when_preauthorized(): void {
+		$post_id = self::factory()->post->create();
+
+		$this->expectException( UnauthorizedException::class );
+		$this->expectExceptionMessage( 'Product not found.' );
+
+		$this->sut->authorize( $post_id, true );
+	}
+
+	/**
+	 * @testdox authorize() throws "Product not found." for a non-positive ID.
+	 *
+	 * @dataProvider provider_non_positive_ids
+	 *
+	 * @param int $id The non-positive ID to reject.
+	 */
+	public function test_authorize_rejects_non_positive_id( int $id ): void {
+		$this->expectException( UnauthorizedException::class );
+		$this->expectExceptionMessage( 'Product not found.' );
+
+		$this->sut->authorize( $id, false );
+	}
+
+	/**
+	 * @return array<string, array{int}>
+	 */
+	public function provider_non_positive_ids(): array {
+		return array(
+			'zero'     => array( 0 ),
+			'negative' => array( -1 ),
+		);
+	}
+
+	/**
+	 * @testdox authorize() throws "Product not found." for a non-existent ID.
+	 */
+	public function test_authorize_rejects_missing_product(): void {
+		$this->expectException( UnauthorizedException::class );
+		$this->expectExceptionMessage( 'Product not found.' );
+
+		$this->sut->authorize( 999999, false );
+	}
+
+	/**
+	 * @testdox authorize() throws "Product not found." for a non-product post.
+	 */
+	public function test_authorize_rejects_non_product_post(): void {
+		$post_id = self::factory()->post->create();
+		$admin   = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin );
+
+		$this->expectException( UnauthorizedException::class );
+		$this->expectExceptionMessage( 'Product not found.' );
+
+		$this->sut->authorize( $post_id, false );
+	}
+
+	/**
+	 * @testdox authorize() throws "Product not found." when a non-owner tries to read.
+	 */
+	public function test_authorize_rejects_non_owner(): void {
+		$owner   = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+		$other   = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+		$product = WC_Helper_Product::create_simple_product();
+		wp_update_post(
+			array(
+				'ID'          => $product->get_id(),
+				'post_author' => $owner,
+			)
+		);
+		wp_set_current_user( $other );
+
+		$this->expectException( UnauthorizedException::class );
+		$this->expectExceptionMessage( 'Product not found.' );
+
+		$this->sut->authorize( $product->get_id(), false );
+	}
+
+	/**
+	 * @testdox authorize() rejects an anonymous caller even when post_author is 0.
+	 */
+	public function test_authorize_rejects_anonymous_caller_for_authorless_product(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		wp_update_post(
+			array(
+				'ID'          => $product->get_id(),
+				'post_author' => 0,
+			)
+		);
+
+		$this->expectException( UnauthorizedException::class );
+		$this->expectExceptionMessage( 'Product not found.' );
+
+		$this->sut->authorize( $product->get_id(), false );
+	}
+
+	/**
+	 * @testdox execute() returns a product DTO for a valid ID.
+	 */
+	public function test_execute_returns_product_for_valid_id(): void {
+		$product = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Test Widget' ) );
+
+		$result = $this->sut->execute( $product->get_id() );
+
+		$this->assertIsObject( $result );
+		$this->assertSame( $product->get_id(), $result->id );
+		$this->assertSame( 'Test Widget', $result->name );
+	}
+
+	/**
+	 * @testdox execute() returns null for a non-positive ID.
+	 */
+	public function test_execute_returns_null_for_non_positive_id(): void {
+		$this->assertNull( $this->sut->execute( 0 ) );
+		$this->assertNull( $this->sut->execute( -1 ) );
+	}
+
+	/**
+	 * @testdox execute() returns null when the ID does not point to a product.
+	 */
+	public function test_execute_returns_null_for_non_product(): void {
+		$post_id = self::factory()->post->create();
+
+		$this->assertNull( $this->sut->execute( $post_id ) );
+		$this->assertNull( $this->sut->execute( 999999 ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Api/Queries/Products/ListProductsTest.php b/plugins/woocommerce/tests/php/src/Api/Queries/Products/ListProductsTest.php
new file mode 100644
index 00000000000..bb25000d955
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Api/Queries/Products/ListProductsTest.php
@@ -0,0 +1,361 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Api\Queries\Products;
+
+use Automattic\WooCommerce\Api\Enums\Products\ProductStatus;
+use Automattic\WooCommerce\Api\Enums\Products\ProductType;
+use Automattic\WooCommerce\Api\Enums\Products\StockStatus;
+use Automattic\WooCommerce\Api\InputTypes\Products\ProductFilterInput;
+use Automattic\WooCommerce\Api\Pagination\IdCursorFilter;
+use Automattic\WooCommerce\Api\Pagination\PaginationParams;
+use Automattic\WooCommerce\Api\Queries\Products\ListProducts;
+use ReflectionClass;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for {@see ListProducts}.
+ */
+class ListProductsTest extends WC_Unit_Test_Case {
+	/**
+	 * The system under test.
+	 *
+	 * @var ListProducts
+	 */
+	private ListProducts $sut;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		// IdCursorFilter registers its posts_where hook once per request and
+		// remembers it on a static flag. WP_UnitTestCase resets $wp_filter on
+		// every tear_down(), so the actual hook is gone but the flag stays
+		// true — leaving subsequent cursor tests without a working filter.
+		$reflection = new ReflectionClass( IdCursorFilter::class );
+		$property   = $reflection->getProperty( 'registered' );
+		$property->setAccessible( true );
+		$property->setValue( null, false );
+
+		$this->sut = new ListProducts();
+	}
+
+	/**
+	 * Build a {@see ProductFilterInput} with the given fields.
+	 *
+	 * @param ?ProductStatus $status       Optional product status filter.
+	 * @param ?StockStatus   $stock_status Optional stock status filter.
+	 * @param ?string        $search       Optional search keyword.
+	 */
+	private function filters(
+		?ProductStatus $status = null,
+		?StockStatus $stock_status = null,
+		?string $search = null,
+	): ProductFilterInput {
+		return new ProductFilterInput( $status, $stock_status, $search );
+	}
+
+	/**
+	 * @testdox execute() returns all products with ascending IDs by default.
+	 */
+	public function test_execute_returns_all_products_in_ascending_order(): void {
+		$a = WC_Helper_Product::create_simple_product();
+		$b = WC_Helper_Product::create_simple_product();
+		$c = WC_Helper_Product::create_simple_product();
+
+		$connection = $this->sut->execute( new PaginationParams(), $this->filters() );
+
+		$this->assertSame( 3, $connection->total_count );
+		$this->assertCount( 3, $connection->nodes );
+		$this->assertSame( $a->get_id(), $connection->nodes[0]->id );
+		$this->assertSame( $b->get_id(), $connection->nodes[1]->id );
+		$this->assertSame( $c->get_id(), $connection->nodes[2]->id );
+	}
+
+	/**
+	 * @testdox execute() honors `first` and signals has_next_page when more remain.
+	 */
+	public function test_execute_paginates_forward_with_first(): void {
+		WC_Helper_Product::create_simple_product();
+		WC_Helper_Product::create_simple_product();
+		WC_Helper_Product::create_simple_product();
+
+		$connection = $this->sut->execute( new PaginationParams( first: 2 ), $this->filters() );
+
+		$this->assertCount( 2, $connection->nodes );
+		$this->assertSame( 3, $connection->total_count );
+		$this->assertTrue( $connection->page_info->has_next_page );
+		$this->assertFalse( $connection->page_info->has_previous_page );
+	}
+
+	/**
+	 * @testdox execute() honors `after` and returns products with IDs > cursor.
+	 */
+	public function test_execute_paginates_forward_with_after_cursor(): void {
+		$first = WC_Helper_Product::create_simple_product();
+		$mid   = WC_Helper_Product::create_simple_product();
+		$last  = WC_Helper_Product::create_simple_product();
+
+		$after_cursor = base64_encode( (string) $first->get_id() );
+
+		$connection = $this->sut->execute(
+			new PaginationParams( first: 10, after: $after_cursor ),
+			$this->filters()
+		);
+
+		$this->assertCount( 2, $connection->nodes );
+		$this->assertSame( $mid->get_id(), $connection->nodes[0]->id );
+		$this->assertSame( $last->get_id(), $connection->nodes[1]->id );
+		$this->assertTrue( $connection->page_info->has_previous_page );
+		$this->assertFalse( $connection->page_info->has_next_page );
+	}
+
+	/**
+	 * @testdox execute() honors `last` and returns the trailing page in ascending order.
+	 */
+	public function test_execute_paginates_backward_with_last(): void {
+		$a = WC_Helper_Product::create_simple_product();
+		$b = WC_Helper_Product::create_simple_product();
+		$c = WC_Helper_Product::create_simple_product();
+
+		$connection = $this->sut->execute( new PaginationParams( last: 2 ), $this->filters() );
+
+		$this->assertCount( 2, $connection->nodes );
+		$this->assertSame( $b->get_id(), $connection->nodes[0]->id );
+		$this->assertSame( $c->get_id(), $connection->nodes[1]->id );
+		$this->assertTrue( $connection->page_info->has_previous_page );
+		$this->assertFalse( $connection->page_info->has_next_page );
+	}
+
+	/**
+	 * @testdox execute() honors `before` and reports has_next_page=true (more remain after the window).
+	 */
+	public function test_execute_paginates_backward_with_before_cursor(): void {
+		$a = WC_Helper_Product::create_simple_product();
+		$b = WC_Helper_Product::create_simple_product();
+		$c = WC_Helper_Product::create_simple_product();
+
+		$before_cursor = base64_encode( (string) $c->get_id() );
+
+		$connection = $this->sut->execute(
+			new PaginationParams( last: 10, before: $before_cursor ),
+			$this->filters()
+		);
+
+		$this->assertCount( 2, $connection->nodes );
+		$this->assertSame( $a->get_id(), $connection->nodes[0]->id );
+		$this->assertSame( $b->get_id(), $connection->nodes[1]->id );
+		$this->assertTrue( $connection->page_info->has_next_page );
+	}
+
+	/**
+	 * @testdox execute() filters by product status.
+	 */
+	public function test_execute_filters_by_status(): void {
+		WC_Helper_Product::create_simple_product( true, array( 'status' => 'publish' ) );
+		$draft = WC_Helper_Product::create_simple_product( true, array( 'status' => 'draft' ) );
+
+		$connection = $this->sut->execute(
+			new PaginationParams(),
+			$this->filters( status: ProductStatus::Draft )
+		);
+
+		$this->assertSame( 1, $connection->total_count );
+		$this->assertCount( 1, $connection->nodes );
+		$this->assertSame( $draft->get_id(), $connection->nodes[0]->id );
+	}
+
+	/**
+	 * @testdox execute() filters by stock_status InStock.
+	 */
+	public function test_execute_filters_by_stock_status_in_stock(): void {
+		$in_stock = WC_Helper_Product::create_simple_product( true, array( 'stock_status' => 'instock' ) );
+		WC_Helper_Product::create_simple_product( true, array( 'stock_status' => 'outofstock' ) );
+
+		$connection = $this->sut->execute(
+			new PaginationParams(),
+			$this->filters( stock_status: StockStatus::InStock )
+		);
+
+		$this->assertCount( 1, $connection->nodes );
+		$this->assertSame( $in_stock->get_id(), $connection->nodes[0]->id );
+	}
+
+	/**
+	 * @testdox execute() filters by stock_status OutOfStock.
+	 */
+	public function test_execute_filters_by_stock_status_out_of_stock(): void {
+		WC_Helper_Product::create_simple_product( true, array( 'stock_status' => 'instock' ) );
+		$out_of_stock = WC_Helper_Product::create_simple_product( true, array( 'stock_status' => 'outofstock' ) );
+
+		$connection = $this->sut->execute(
+			new PaginationParams(),
+			$this->filters( stock_status: StockStatus::OutOfStock )
+		);
+
+		$this->assertCount( 1, $connection->nodes );
+		$this->assertSame( $out_of_stock->get_id(), $connection->nodes[0]->id );
+	}
+
+	/**
+	 * @testdox execute() filters by stock_status OnBackorder.
+	 */
+	public function test_execute_filters_by_stock_status_on_backorder(): void {
+		WC_Helper_Product::create_simple_product( true, array( 'stock_status' => 'instock' ) );
+		$on_backorder = WC_Helper_Product::create_simple_product( true, array( 'stock_status' => 'onbackorder' ) );
+
+		$connection = $this->sut->execute(
+			new PaginationParams(),
+			$this->filters( stock_status: StockStatus::OnBackorder )
+		);
+
+		$this->assertCount( 1, $connection->nodes );
+		$this->assertSame( $on_backorder->get_id(), $connection->nodes[0]->id );
+	}
+
+	/**
+	 * @testdox execute() filters by stock_status Other (non-standard values).
+	 */
+	public function test_execute_filters_by_stock_status_other(): void {
+		WC_Helper_Product::create_simple_product( true, array( 'stock_status' => 'instock' ) );
+		$custom = WC_Helper_Product::create_simple_product();
+		update_post_meta( $custom->get_id(), '_stock_status', 'plugin_custom' );
+
+		$connection = $this->sut->execute(
+			new PaginationParams(),
+			$this->filters( stock_status: StockStatus::Other )
+		);
+
+		$this->assertCount( 1, $connection->nodes );
+		$this->assertSame( $custom->get_id(), $connection->nodes[0]->id );
+	}
+
+	/**
+	 * @testdox execute() filters by product_type Simple.
+	 */
+	public function test_execute_filters_by_product_type_simple(): void {
+		$simple   = WC_Helper_Product::create_simple_product();
+		$external = WC_Helper_Product::create_external_product();
+
+		$connection = $this->sut->execute(
+			new PaginationParams(),
+			$this->filters(),
+			ProductType::Simple
+		);
+
+		$ids = array_map( static fn( $node ): int => $node->id, $connection->nodes );
+		$this->assertContains( $simple->get_id(), $ids );
+		$this->assertNotContains( $external->get_id(), $ids );
+	}
+
+	/**
+	 * @testdox execute() filters by product_type Other (non-standard types).
+	 */
+	public function test_execute_filters_by_product_type_other(): void {
+		$simple = WC_Helper_Product::create_simple_product();
+		$custom = WC_Helper_Product::create_simple_product();
+		wp_set_object_terms( $custom->get_id(), 'plugin_custom_type', 'product_type' );
+
+		$connection = $this->sut->execute(
+			new PaginationParams(),
+			$this->filters(),
+			ProductType::Other
+		);
+
+		$ids = array_map( static fn( $node ): int => $node->id, $connection->nodes );
+		$this->assertContains( $custom->get_id(), $ids );
+		$this->assertNotContains( $simple->get_id(), $ids );
+	}
+
+	/**
+	 * @testdox execute() filters by search keyword against the product name.
+	 */
+	public function test_execute_filters_by_search(): void {
+		$widget = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Blue Widget' ) );
+		WC_Helper_Product::create_simple_product( true, array( 'name' => 'Red Gadget' ) );
+
+		$connection = $this->sut->execute(
+			new PaginationParams(),
+			$this->filters( search: 'Widget' )
+		);
+
+		$this->assertCount( 1, $connection->nodes );
+		$this->assertSame( $widget->get_id(), $connection->nodes[0]->id );
+	}
+
+	/**
+	 * @testdox execute() reports total_count after filters, not the unfiltered total.
+	 */
+	public function test_total_count_reflects_filters(): void {
+		WC_Helper_Product::create_simple_product( true, array( 'status' => 'publish' ) );
+		WC_Helper_Product::create_simple_product( true, array( 'status' => 'publish' ) );
+		WC_Helper_Product::create_simple_product( true, array( 'status' => 'draft' ) );
+
+		$connection = $this->sut->execute(
+			new PaginationParams( first: 1 ),
+			$this->filters( status: ProductStatus::Published )
+		);
+
+		$this->assertSame( 2, $connection->total_count );
+		$this->assertCount( 1, $connection->nodes );
+	}
+
+	/**
+	 * @testdox each edge carries a base64-encoded ID cursor.
+	 */
+	public function test_edges_carry_base64_id_cursors(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$connection = $this->sut->execute( new PaginationParams(), $this->filters() );
+
+		$this->assertCount( 1, $connection->edges );
+		$this->assertSame( base64_encode( (string) $product->get_id() ), $connection->edges[0]->cursor );
+		$this->assertSame( $product->get_id(), $connection->edges[0]->node->id );
+	}
+
+	/**
+	 * @testdox start_cursor and end_cursor on page_info mirror the first and last edge cursors.
+	 */
+	public function test_page_info_carries_start_and_end_cursors(): void {
+		$first = WC_Helper_Product::create_simple_product();
+		WC_Helper_Product::create_simple_product();
+		$last = WC_Helper_Product::create_simple_product();
+
+		$connection = $this->sut->execute( new PaginationParams(), $this->filters() );
+
+		$this->assertSame( base64_encode( (string) $first->get_id() ), $connection->page_info->start_cursor );
+		$this->assertSame( base64_encode( (string) $last->get_id() ), $connection->page_info->end_cursor );
+	}
+
+	/**
+	 * @testdox an empty result set returns no edges and null start/end cursors.
+	 */
+	public function test_empty_result_set(): void {
+		$connection = $this->sut->execute( new PaginationParams(), $this->filters() );
+
+		$this->assertSame( 0, $connection->total_count );
+		$this->assertSame( array(), $connection->edges );
+		$this->assertSame( array(), $connection->nodes );
+		$this->assertNull( $connection->page_info->start_cursor );
+		$this->assertNull( $connection->page_info->end_cursor );
+		$this->assertFalse( $connection->page_info->has_next_page );
+		$this->assertFalse( $connection->page_info->has_previous_page );
+	}
+
+	/**
+	 * @testdox first=N with exactly N matching products reports has_next_page=false.
+	 */
+	public function test_first_equal_to_total_reports_no_next_page(): void {
+		WC_Helper_Product::create_simple_product();
+		WC_Helper_Product::create_simple_product();
+
+		$connection = $this->sut->execute( new PaginationParams( first: 2 ), $this->filters() );
+
+		$this->assertCount( 2, $connection->nodes );
+		$this->assertSame( 2, $connection->total_count );
+		$this->assertFalse( $connection->page_info->has_next_page );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/README.md b/plugins/woocommerce/tests/php/src/Internal/Api/README.md
index a4bda7752fa..ddb042f896f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/README.md
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/README.md
@@ -2,6 +2,8 @@

 Unit tests for the manually-maintained code under `src/Internal/Api/` and the resolver tree generated by `ApiBuilder` against a dummy code-API kept under `Fixtures/DummyApi/`.

+The companion testsuite `wc-phpunit-graphql-api` covers the command tree under `src/Api/Queries/` and `src/Api/Mutations/` (products, coupons, …); its tests live under `tests/php/src/Api/Queries/` and `tests/php/src/Api/Mutations/`.
+
 ## These tests do not run by default

 The dummy fixture API uses PHP 8.1+ syntax (enums, named arguments, `readonly` promoted properties, etc.). PHPUnit cannot even parse those files on PHP 7.4 / 8.0, so this directory is excluded from the default testsuite (`wc-phpunit-default` in `phpunit.xml`).
@@ -16,7 +18,10 @@ Pick one of the following dedicated testsuites:
 # Just the GraphQL infrastructure tests:
 pnpm test:php:env -- --testsuite=wc-phpunit-graphql-infra

-# Or the full set (legacy + main + this directory):
+# Just the GraphQL public API command tests:
+pnpm test:php:env -- --testsuite=wc-phpunit-graphql-api
+
+# Or the full set (legacy + main + both GraphQL suites):
 pnpm test:php:env -- --testsuite=wc-phpunit-full
 ```

@@ -24,6 +29,7 @@ Without `pnpm`:

 ```sh
 ./vendor/bin/phpunit -c phpunit.xml --testsuite=wc-phpunit-graphql-infra
+./vendor/bin/phpunit -c phpunit.xml --testsuite=wc-phpunit-graphql-api
 ./vendor/bin/phpunit -c phpunit.xml --testsuite=wc-phpunit-full
 ```