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
```