Commit 795e653c0b9 for woocommerce
commit 795e653c0b955b4c61826386115fdff2c6527a11
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Thu Apr 30 10:09:07 2026 +0200
Add unit tests for GraphQL infrastructure (#64479)
diff --git a/plugins/woocommerce/changelog/add-graphql-infrastructure-unit-tests b/plugins/woocommerce/changelog/add-graphql-infrastructure-unit-tests
new file mode 100644
index 00000000000..e3572b20295
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-graphql-infrastructure-unit-tests
@@ -0,0 +1,6 @@
+Significance: minor
+Type: dev
+
+Comment: Add unit tests for the experimental GraphQL infrastructure
+
+
diff --git a/plugins/woocommerce/package.json b/plugins/woocommerce/package.json
index 90be2cb050c..4e716fcf9a5 100644
--- a/plugins/woocommerce/package.json
+++ b/plugins/woocommerce/package.json
@@ -24,6 +24,7 @@
"build:project:actualize-translation-domains": "wireit",
"build:api": "php src/Internal/Api/DesignTime/Scripts/build-api.php",
"build:api:check": "php src/Internal/Api/DesignTime/Scripts/check-api-staleness.php",
+ "build:api:test": "php src/Internal/Api/DesignTime/Scripts/build-api.php --api-dir=tests/php/src/Internal/Api/Fixtures/DummyApi --autogen-dir=tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated --api-namespace='Automattic\\WooCommerce\\Tests\\Internal\\Api\\Fixtures\\DummyApi' --autogen-namespace='Automattic\\WooCommerce\\Tests\\Internal\\Api\\Fixtures\\DummyApiAutogenerated'",
"changelog": "XDEBUG_MODE=off composer install --quiet && composer exec -- changelogger",
"update:php": "XDEBUG_MODE=off composer update --quiet",
"env:destroy": "pnpm wp-env destroy",
@@ -131,7 +132,8 @@
"command": "test:php:env",
"shardingArguments": [
"--testsuite=wc-phpunit-legacy",
- "--testsuite=wc-phpunit-main"
+ "--testsuite=wc-phpunit-main",
+ "--testsuite=wc-phpunit-graphql-infra"
],
"onlyForDependencies": [],
"changes": [
@@ -161,7 +163,8 @@
"command": "test:php:legacy-mini-cart",
"shardingArguments": [
"--testsuite=wc-phpunit-legacy",
- "--testsuite=wc-phpunit-main"
+ "--testsuite=wc-phpunit-main",
+ "--testsuite=wc-phpunit-graphql-infra"
],
"onlyForDependencies": [],
"changes": [
@@ -591,7 +594,8 @@
"command": "test:php:env:hpos-off",
"shardingArguments": [
"--testsuite=wc-phpunit-legacy",
- "--testsuite=wc-phpunit-main"
+ "--testsuite=wc-phpunit-main",
+ "--testsuite=wc-phpunit-graphql-infra"
],
"onlyForDependencies": [],
"changes": [
diff --git a/plugins/woocommerce/phpcs.xml b/plugins/woocommerce/phpcs.xml
index 8a2c570e4b8..2eb32ba7931 100644
--- a/plugins/woocommerce/phpcs.xml
+++ b/plugins/woocommerce/phpcs.xml
@@ -17,6 +17,8 @@
<exclude-pattern>*/vendor/*</exclude-pattern>
<exclude-pattern>lib/</exclude-pattern>
<exclude-pattern>php-stubs/</exclude-pattern>
+ <!-- GraphQL test fixtures used only by unit tests. -->
+ <exclude-pattern>tests/php/src/Internal/Api/Fixtures/</exclude-pattern>
<!-- Only check PHP files. -->
<arg name="extensions" value="php" />
diff --git a/plugins/woocommerce/phpunit.xml b/plugins/woocommerce/phpunit.xml
index 9e994f7f7cd..2495107ab14 100644
--- a/plugins/woocommerce/phpunit.xml
+++ b/plugins/woocommerce/phpunit.xml
@@ -7,8 +7,15 @@
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
verbose="true"
+ defaultTestSuite="wc-phpunit-default"
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`. -->
<testsuite name="wc-phpunit-legacy">
<directory suffix=".php">./tests/legacy/unit-tests</directory>
</testsuite>
@@ -16,6 +23,28 @@
<directory suffix=".php">./tests/php</directory>
<exclude>./tests/php/helpers</exclude>
<exclude>./tests/php/src/Blocks/BlockPatterns/patterns</exclude>
+ <exclude>./tests/php/src/Internal/Api</exclude>
+ </testsuite>
+ <testsuite name="wc-phpunit-graphql-infra">
+ <directory suffix=".php">./tests/php/src/Internal/Api</directory>
+ </testsuite>
+
+ <!-- Umbrella suites used for local invocation. PHPUnit does not
+ support testsuite composition by reference, so the directory
+ and exclude lists below are duplicated from the granular
+ suites above. Keep them in sync if either of those changes. -->
+ <testsuite name="wc-phpunit-default">
+ <directory suffix=".php">./tests/legacy/unit-tests</directory>
+ <directory suffix=".php">./tests/php</directory>
+ <exclude>./tests/php/helpers</exclude>
+ <exclude>./tests/php/src/Blocks/BlockPatterns/patterns</exclude>
+ <exclude>./tests/php/src/Internal/Api</exclude>
+ </testsuite>
+ <testsuite name="wc-phpunit-full">
+ <directory suffix=".php">./tests/legacy/unit-tests</directory>
+ <directory suffix=".php">./tests/php</directory>
+ <exclude>./tests/php/helpers</exclude>
+ <exclude>./tests/php/src/Blocks/BlockPatterns/patterns</exclude>
</testsuite>
</testsuites>
<listeners>
diff --git a/plugins/woocommerce/tests/legacy/bootstrap.php b/plugins/woocommerce/tests/legacy/bootstrap.php
index 50e17c6e5b5..3f2e02d7d29 100644
--- a/plugins/woocommerce/tests/legacy/bootstrap.php
+++ b/plugins/woocommerce/tests/legacy/bootstrap.php
@@ -88,6 +88,8 @@ class WC_Unit_Tests_Bootstrap {
// load the WP testing environment.
require_once $this->wp_tests_dir . '/includes/bootstrap.php';
+ $this->maybe_announce_skipped_graphql_infra_tests();
+
// Ensure theme install tests use direct filesystem method.
if ( ! defined( 'FS_METHOD' ) ) {
define( 'FS_METHOD', 'direct' );
@@ -177,6 +179,27 @@ class WC_Unit_Tests_Bootstrap {
\Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::toggle_cot_feature_and_usage( ! $disable_hpos );
}
+ /**
+ * 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.
+ *
+ * The GraphQL infrastructure tests live in their own suite 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();
+ 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 ) {
+ return;
+ }
+ }
+
+ echo 'Not running GraphQL infrastructure tests. To execute these, use --testsuite=wc-phpunit-graphql-infra or wc-phpunit-full.' . PHP_EOL;
+ }
+
/**
* Re-initialize the dependency injection engine.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AttributeInheritanceTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AttributeInheritanceTest.php
new file mode 100644
index 00000000000..31ea18a11ee
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AttributeInheritanceTest.php
@@ -0,0 +1,113 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+/**
+ * Tests for capability/access-attribute inheritance.
+ *
+ * The builder treats #[RequiredCapability] / #[PublicAccess] as inheritable
+ * from three sources: parent classes, traits, and PHP interfaces. A direct
+ * attribute on the class itself takes precedence; otherwise caps from every
+ * source are merged. These tests verify both the generated check list and
+ * the runtime behaviour.
+ */
+class AttributeInheritanceTest extends AutogeneratedTestCase {
+ /**
+ * Read the autogenerated resolver source for a given query class.
+ *
+ * @param string $class_name The short PHP class name (e.g. `InheritedCapQuery`).
+ */
+ private function read_generated_resolver( string $class_name ): string {
+ $path = __DIR__ . '/../Fixtures/DummyApiAutogenerated/GraphQLQueries/' . $class_name . '.php';
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ return (string) file_get_contents( $path );
+ }
+
+ /**
+ * @testdox a query class inherits #[RequiredCapability] from its abstract parent.
+ */
+ public function test_required_capability_inherited_from_parent(): void {
+ $source = $this->read_generated_resolver( 'InheritedCapQuery' );
+ $this->assertStringContainsString( "Utils::check_current_user_can( 'manage_options' )", $source );
+
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+ $ok = $this->execute_query( '{ inheritedCap { result } }' );
+ $this->assertSame( 'inherited cap', $ok['data']['inheritedCap']['result'] ?? null );
+
+ wp_set_current_user( 0 );
+ $rejected = $this->execute_query( '{ inheritedCap { result } }' );
+ $this->assertSame( 'UNAUTHORIZED', $rejected['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox a query class inherits #[PublicAccess] from a trait.
+ */
+ public function test_public_access_inherited_from_trait(): void {
+ $source = $this->read_generated_resolver( 'InheritedPublicQuery' );
+ $this->assertStringNotContainsString( 'check_current_user_can', $source );
+
+ wp_set_current_user( 0 );
+ $result = $this->execute_query( '{ inheritedPublic { result } }' );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $this->assertSame( 'inherited public', $result['data']['inheritedPublic']['result'] ?? null );
+ }
+
+ /**
+ * @testdox a query class inherits #[RequiredCapability] from a PHP interface.
+ */
+ public function test_required_capability_inherited_from_interface(): void {
+ $source = $this->read_generated_resolver( 'InheritedFromInterfaceQuery' );
+ $this->assertStringContainsString( "Utils::check_current_user_can( 'manage_options' )", $source );
+
+ wp_set_current_user( 0 );
+ $result = $this->execute_query( '{ inheritedFromInterface { result } }' );
+ $this->assertSame( 'UNAUTHORIZED', $result['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox a direct attribute overrides any inherited capability.
+ */
+ public function test_direct_attribute_overrides_inherited_capability(): void {
+ $source = $this->read_generated_resolver( 'OverriddenCapQuery' );
+ $this->assertStringContainsString( "Utils::check_current_user_can( 'manage_categories' )", $source );
+ $this->assertStringNotContainsString( "Utils::check_current_user_can( 'manage_options' )", $source );
+
+ // `editor` has manage_categories but NOT manage_options. If the
+ // inherited cap were used, this would fail.
+ $editor = self::factory()->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $editor );
+ $result = $this->execute_query( '{ overriddenCap { result } }' );
+ $this->assertSame( 'overridden cap', $result['data']['overriddenCap']['result'] ?? null );
+
+ // And the same editor still fails the inherited-cap variant — proving
+ // the difference is real, not a permissive role.
+ $inherited = $this->execute_query( '{ inheritedCap { result } }' );
+ $this->assertSame( 'UNAUTHORIZED', $inherited['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox capabilities inherited from multiple sources are all enforced.
+ */
+ public function test_capabilities_from_multiple_sources_are_merged(): void {
+ $source = $this->read_generated_resolver( 'MergedCapsQuery' );
+ // Both the parent's manage_options and the trait's edit_posts must
+ // be present in the generated checks.
+ $this->assertStringContainsString( "Utils::check_current_user_can( 'manage_options' )", $source );
+ $this->assertStringContainsString( "Utils::check_current_user_can( 'edit_posts' )", $source );
+
+ // `editor` has edit_posts but NOT manage_options → must fail.
+ $editor = self::factory()->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $editor );
+ $rejected = $this->execute_query( '{ mergedCaps { result } }' );
+ $this->assertSame( 'UNAUTHORIZED', $rejected['errors'][0]['extensions']['code'] ?? null );
+
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+ $ok = $this->execute_query( '{ mergedCaps { result } }' );
+ $this->assertSame( 'merged caps', $ok['data']['mergedCaps']['result'] ?? null );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AttributesTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AttributesTest.php
new file mode 100644
index 00000000000..b159c37e317
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AttributesTest.php
@@ -0,0 +1,228 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Priority as PriorityEnum;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Enums\Priority as PriorityEnumType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Input\CreateWidget as CreateWidgetInputType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Input\WidgetFilter as WidgetFilterInputType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Interfaces\Identifiable as IdentifiableInterfaceType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Interfaces\Named as NamedInterfaceType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Gadget as GadgetType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget as WidgetType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\RootMutationType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\RootQueryType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\ListOfType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\NonNull;
+
+/**
+ * Tests that every code-API attribute is reflected in the autogenerated
+ * artifacts. One assertion per attribute keeps any future regression
+ * narrowly localized.
+ */
+class AttributesTest extends AutogeneratedTestCase {
+ /**
+ * @testdox #[Description] on a class becomes the GraphQL description of the type.
+ */
+ public function test_description_attribute_is_emitted_on_type(): void {
+ $this->assertSame( 'A dummy widget that exercises every output-type attribute', WidgetType::get()->description );
+ }
+
+ /**
+ * @testdox #[Description] on a property becomes the GraphQL description of the field.
+ */
+ public function test_description_attribute_is_emitted_on_field(): void {
+ $slug = WidgetType::get()->getField( 'slug' );
+ $this->assertSame( 'A short slug', $slug->description );
+ }
+
+ /**
+ * @testdox #[Name] at the class level renames the GraphQL type.
+ */
+ public function test_name_attribute_renames_classes(): void {
+ // Object types: Gadget → GadgetType (renamed), Widget unchanged.
+ $this->assertSame( 'GadgetType', GadgetType::get()->name );
+ $this->assertSame( 'Widget', WidgetType::get()->name );
+
+ // Enums: Priority → TaskPriority (renamed), Color unchanged.
+ $this->assertSame( 'TaskPriority', PriorityEnumType::get()->name );
+
+ // Input types: WidgetFilterInput → WidgetFilterArgs (renamed), CreateWidgetInput unchanged.
+ $this->assertSame( 'WidgetFilterArgs', WidgetFilterInputType::get()->name );
+ $this->assertSame( 'CreateWidgetInput', CreateWidgetInputType::get()->name );
+
+ // Interfaces: Identifiable → HasId (renamed), Named unchanged.
+ $this->assertSame( 'HasId', IdentifiableInterfaceType::get()->name );
+ $this->assertSame( 'Named', NamedInterfaceType::get()->name );
+
+ // Queries / mutations: Name renames the field on the root type.
+ $query_fields = RootQueryType::get()->getFields();
+ $this->assertArrayHasKey( 'widget', $query_fields );
+ $this->assertArrayHasKey( 'widgets', $query_fields );
+ $this->assertArrayHasKey( 'greeting', $query_fields );
+ $this->assertArrayHasKey( 'namedThing', $query_fields );
+ $this->assertArrayHasKey( 'failing', $query_fields );
+
+ $mutation_fields = RootMutationType::get()->getFields();
+ $this->assertArrayHasKey( 'increment', $mutation_fields );
+ }
+
+ /**
+ * @testdox #[Name] at the enum-case level renames the GraphQL value.
+ */
+ public function test_name_attribute_renames_enum_cases(): void {
+ $names = array_map( static fn( $v ) => $v->name, PriorityEnumType::get()->getValues() );
+ // Default case-name → SCREAMING_SNAKE_CASE: 'Low' → 'LOW', 'High' → 'HIGH'.
+ // Renamed via #[Name('NORMAL_PRIORITY')]: 'Normal' → 'NORMAL_PRIORITY'.
+ $this->assertContains( 'LOW', $names );
+ $this->assertContains( 'NORMAL_PRIORITY', $names );
+ $this->assertContains( 'HIGH', $names );
+ $this->assertNotContains( 'NORMAL', $names );
+ }
+
+ /**
+ * @testdox #[Deprecated] on an enum case sets the deprecationReason.
+ */
+ public function test_deprecated_on_enum_case_sets_deprecation_reason(): void {
+ $value = PriorityEnumType::get()->getValue( 'HIGH' );
+ $this->assertSame( 'Use NORMAL_PRIORITY instead.', $value->deprecationReason );
+ $this->assertTrue( $value->isDeprecated() );
+ }
+
+ /**
+ * @testdox #[Deprecated] on a property sets the deprecationReason on the field.
+ */
+ public function test_deprecated_on_property_sets_deprecation_reason(): void {
+ $field = WidgetType::get()->getField( 'legacy_price' );
+ $this->assertSame( 'Use price instead.', $field->deprecationReason );
+ $this->assertTrue( $field->isDeprecated() );
+ }
+
+ /**
+ * @testdox #[Ignore] on a property removes the field from the schema.
+ */
+ public function test_ignore_on_property_drops_the_field(): void {
+ $fields = WidgetType::get()->getFields();
+ $this->assertArrayNotHasKey( 'internal_notes', $fields );
+ }
+
+ /**
+ * @testdox #[ArrayOf] with a scalar produces a list of non-null scalars.
+ */
+ public function test_array_of_scalar_produces_list_of_non_null(): void {
+ $field = WidgetType::get()->getField( 'tag_ids' );
+ $type = $this->unwrap_non_null( $field->getType() );
+ $this->assertInstanceOf( ListOfType::class, $type );
+ $inner = $type->getWrappedType();
+ $this->assertInstanceOf( NonNull::class, $inner );
+ }
+
+ /**
+ * @testdox #[ArrayOf] with a class produces a list of that ObjectType.
+ */
+ public function test_array_of_class_produces_list_of_object_type(): void {
+ $field = WidgetType::get()->getField( 'featured_reviews' );
+ $type = $this->unwrap_non_null( $field->getType() );
+ $this->assertInstanceOf( ListOfType::class, $type );
+ $inner = $this->unwrap_non_null( $type->getWrappedType() );
+ $this->assertSame( 'WidgetReview', $inner->name );
+ }
+
+ /**
+ * @testdox #[ConnectionOf] produces a NodeNameConnection field.
+ */
+ public function test_connection_of_produces_connection_type(): void {
+ $field = WidgetType::get()->getField( 'reviews' );
+ $type = $this->unwrap_non_null( $field->getType() );
+ $this->assertSame( 'WidgetReviewConnection', $type->name );
+ }
+
+ /**
+ * @testdox #[ScalarType] points the generated field at the custom scalar.
+ */
+ public function test_scalar_type_overrides_the_field_type(): void {
+ $field = WidgetType::get()->getField( 'date_created' );
+ $this->assertSame( 'DummyDateTime', $field->getType()->name );
+ }
+
+ /**
+ * @testdox #[Parameter] adds a named argument to a field.
+ */
+ public function test_parameter_attribute_adds_field_argument(): void {
+ $field = WidgetType::get()->getField( 'price' );
+ $arg_defs = $field->args;
+ $this->assertNotEmpty( $arg_defs );
+
+ $arg_names = array_map( static fn( $a ) => $a->name, $arg_defs );
+ $this->assertContains( 'formatted', $arg_names );
+ }
+
+ /**
+ * @testdox #[ParameterDescription] sets the description of a #[Parameter]-declared argument.
+ */
+ public function test_parameter_description_sets_argument_description(): void {
+ $field = WidgetType::get()->getField( 'price' );
+ $args = $field->args;
+ $arg = null;
+ foreach ( $args as $candidate ) {
+ if ( 'formatted' === $candidate->name ) {
+ $arg = $candidate;
+ break;
+ }
+ }
+ $this->assertNotNull( $arg );
+ $this->assertSame( 'When true, prepend a $ sign', $arg->description );
+ }
+
+ /**
+ * @testdox #[PublicAccess] removes capability checks from the generated resolver.
+ */
+ public function test_public_access_skips_capability_checks(): void {
+ // `greeting` is public; even an anonymous user can call it.
+ wp_set_current_user( 0 );
+ $result = $this->execute_query( '{ greeting { result } }' );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $this->assertSame( 'Hello, world!', $result['data']['greeting']['result'] ?? null );
+ }
+
+ /**
+ * @testdox #[RequiredCapability] enforces the capability at resolve time.
+ */
+ public function test_required_capability_enforces_capability(): void {
+ wp_set_current_user( 0 );
+ $result = $this->execute_query( '{ widget(id: 1) { id } }' );
+
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertSame( 'UNAUTHORIZED', $result['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox #[ReturnType] points the generated field at an interface type.
+ */
+ public function test_return_type_uses_interface_type(): void {
+ $field = RootQueryType::get()->getField( 'namedThing' );
+ $type = $this->unwrap_non_null( $field->getType() );
+ $this->assertSame( 'Named', $type->name );
+ }
+
+ /**
+ * @testdox enum values that lack #[Description] omit the description in the schema.
+ */
+ public function test_enum_value_without_description_has_no_description(): void {
+ $value = PriorityEnumType::get()->getValue( 'NORMAL_PRIORITY' );
+ // Either null or empty — the absence of #[Description] should not
+ // fabricate one.
+ $this->assertEmpty( $value->description );
+ }
+
+ /**
+ * @testdox enum values backed by the PHP enum carry the underlying case as their value.
+ */
+ public function test_enum_value_is_backed_by_php_enum_case(): void {
+ $value = PriorityEnumType::get()->getValue( 'HIGH' );
+ $this->assertSame( PriorityEnum::High, $value->value );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AuthorizeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AuthorizeTest.php
new file mode 100644
index 00000000000..877b4409ddc
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AuthorizeTest.php
@@ -0,0 +1,119 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+/**
+ * Tests for the authorize() method support: standalone, with #[Ignore],
+ * combined with an inherited cap (override mode), and composed with a
+ * direct cap via the $_preauthorized infrastructure parameter.
+ */
+class AuthorizeTest extends AutogeneratedTestCase {
+ /**
+ * Read the autogenerated resolver source for a given query class.
+ *
+ * @param string $class_name The short PHP class name.
+ */
+ private function read_generated_resolver( string $class_name ): string {
+ $path = __DIR__ . '/../Fixtures/DummyApiAutogenerated/GraphQLQueries/' . $class_name . '.php';
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ return (string) file_get_contents( $path );
+ }
+
+ /**
+ * @testdox authorize() is the sole guard when no auth attribute is declared.
+ */
+ public function test_authorize_only_query_uses_authorize_method(): void {
+ $source = $this->read_generated_resolver( 'AuthorizeOnlyQuery' );
+ $this->assertStringNotContainsString( 'check_current_user_can', $source );
+ $this->assertStringContainsString( 'Utils::authorize_command', $source );
+
+ wp_set_current_user( 0 );
+
+ $allowed = $this->execute_query( '{ authorizeOnly(allow: true) { result } }' );
+ $this->assertSame( 'allowed', $allowed['data']['authorizeOnly']['result'] ?? null );
+
+ $denied = $this->execute_query( '{ authorizeOnly(allow: false) { result } }' );
+ $this->assertSame( 'UNAUTHORIZED', $denied['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox #[Ignore] on authorize() suppresses it; the cap check applies instead.
+ */
+ public function test_ignored_authorize_method_is_skipped(): void {
+ $source = $this->read_generated_resolver( 'IgnoredAuthorizeQuery' );
+ // Cap check is generated; authorize_command is not.
+ $this->assertStringContainsString( "Utils::check_current_user_can( 'manage_options' )", $source );
+ $this->assertStringNotContainsString( 'authorize_command', $source );
+
+ // Even though the fixture's authorize() returns false, an admin must
+ // succeed — confirming the method is skipped at generation time.
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+ $ok = $this->execute_query( '{ ignoredAuthorize { result } }' );
+ $this->assertSame( 'cap enforced', $ok['data']['ignoredAuthorize']['result'] ?? null );
+
+ wp_set_current_user( 0 );
+ $rejected = $this->execute_query( '{ ignoredAuthorize { result } }' );
+ $this->assertSame( 'UNAUTHORIZED', $rejected['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox a direct authorize() supersedes a cap inherited from a parent class.
+ */
+ public function test_direct_authorize_overrides_inherited_capability(): void {
+ $source = $this->read_generated_resolver( 'OverriddenAuthorizeQuery' );
+ // The inherited manage_options cap MUST NOT be turned into a check —
+ // authorize() takes over as the sole guard.
+ $this->assertStringNotContainsString( 'check_current_user_can', $source );
+ $this->assertStringContainsString( 'Utils::authorize_command', $source );
+
+ // `editor` has edit_posts but not manage_options. authorize() returns
+ // true → succeeds despite the inherited cap pointing to manage_options.
+ $editor = self::factory()->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $editor );
+ $ok = $this->execute_query( '{ overriddenAuthorize { result } }' );
+ $this->assertSame( 'authorize wins', $ok['data']['overriddenAuthorize']['result'] ?? null );
+
+ // `subscriber` has neither cap → authorize() returns false → rejected.
+ $subscriber = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ wp_set_current_user( $subscriber );
+ $rejected = $this->execute_query( '{ overriddenAuthorize { result } }' );
+ $this->assertSame( 'UNAUTHORIZED', $rejected['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox $_preauthorized carries the result of the attribute-driven cap check into authorize().
+ */
+ public function test_composed_authorize_receives_preauthorized_flag(): void {
+ $source = $this->read_generated_resolver( 'ComposedAuthorizeQuery' );
+ // The cap is NOT directly checked; instead its result is passed to
+ // authorize() as `_preauthorized`.
+ $this->assertStringContainsString(
+ "'_preauthorized' => current_user_can( 'manage_options' )",
+ $source
+ );
+ // And no top-level check_current_user_can is generated — authorize()
+ // stays the sole guard.
+ $this->assertStringNotContainsString( 'Utils::check_current_user_can', $source );
+
+ // admin: has manage_options → _preauthorized=true → authorize returns true.
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+ $admin_ok = $this->execute_query( '{ composedAuthorize { result } }' );
+ $this->assertSame( 'composed', $admin_ok['data']['composedAuthorize']['result'] ?? null );
+
+ // editor: lacks manage_options → _preauthorized=false, BUT has edit_posts
+ // → authorize falls back to the secondary cap and still allows the call.
+ $editor = self::factory()->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $editor );
+ $editor_ok = $this->execute_query( '{ composedAuthorize { result } }' );
+ $this->assertSame( 'composed', $editor_ok['data']['composedAuthorize']['result'] ?? null );
+
+ // anonymous: neither cap → authorize returns false → rejected.
+ wp_set_current_user( 0 );
+ $rejected = $this->execute_query( '{ composedAuthorize { result } }' );
+ $this->assertSame( 'UNAUTHORIZED', $rejected['errors'][0]['extensions']['code'] ?? null );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AutogeneratedTestCase.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AutogeneratedTestCase.php
new file mode 100644
index 00000000000..386a2233d9b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AutogeneratedTestCase.php
@@ -0,0 +1,110 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container as DummyContainer;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\RootMutationType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\RootQueryType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\TypeRegistry;
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\Schema;
+use Automattic\WooCommerce\Vendor\GraphQL\GraphQL;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\InputObjectType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\ListOfType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\NonNull;
+use WC_Unit_Test_Case;
+
+/**
+ * Base class for tests that exercise the dummy API's autogenerated artifacts.
+ *
+ * Provides:
+ * - {@see self::build_schema()} — a Schema wired against the dummy
+ * RootQueryType / RootMutationType / TypeRegistry.
+ * - {@see self::execute_query()} — runs a query against that schema and
+ * returns the result array (data + errors).
+ * - {@see self::unwrap_non_null()} / {@see self::unwrap_list()} — helpers to
+ * dig past the NonNull/ListOf wrappers without leaking them into individual
+ * test assertions.
+ */
+abstract class AutogeneratedTestCase extends WC_Unit_Test_Case {
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ DummyStore::reset();
+ DummyContainer::reset();
+ wp_set_current_user( 0 );
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ DummyStore::reset();
+ DummyContainer::reset();
+ wp_set_current_user( 0 );
+ parent::tearDown();
+ }
+
+ /**
+ * Build the dummy schema from its autogenerated parts.
+ */
+ protected function build_schema(): Schema {
+ return new Schema(
+ array(
+ 'query' => RootQueryType::get(),
+ 'mutation' => RootMutationType::get(),
+ 'types' => TypeRegistry::get_interface_implementors(),
+ )
+ );
+ }
+
+ /**
+ * Execute a GraphQL query against the dummy schema.
+ *
+ * @param string $query The query source.
+ * @param array $vars Variable values.
+ *
+ * @return array{data?: ?array, errors?: array}
+ */
+ protected function execute_query( string $query, array $vars = array() ): array {
+ $result = GraphQL::executeQuery(
+ schema: $this->build_schema(),
+ source: $query,
+ variableValues: $vars,
+ );
+ return $result->toArray();
+ }
+
+ /**
+ * Strip a NonNull wrapper if present.
+ *
+ * @param mixed $type Webonyx type instance.
+ */
+ protected function unwrap_non_null( $type ) {
+ return $type instanceof NonNull ? $type->getWrappedType() : $type;
+ }
+
+ /**
+ * Strip a ListOf wrapper if present.
+ *
+ * @param mixed $type Webonyx type instance.
+ */
+ protected function unwrap_list( $type ) {
+ return $type instanceof ListOfType ? $type->getWrappedType() : $type;
+ }
+
+ /**
+ * Return the named field from an ObjectType / InputObjectType.
+ *
+ * @param ObjectType|InputObjectType $object_type The type to inspect.
+ * @param string $field_name The field to look up.
+ */
+ protected function get_field( $object_type, string $field_name ) {
+ return $object_type->getField( $field_name );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/CustomScalarsTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/CustomScalarsTest.php
new file mode 100644
index 00000000000..c102237d374
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/CustomScalarsTest.php
@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Internal\Api\Schema\AST\StringValueNode;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Scalars\DummyDateTime as DummyDateTimeType;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\IntValueNode;
+
+/**
+ * Tests for the custom-scalar generation: serialization round-trip, parseValue
+ * for variables, parseLiteral for inline literals, and rejection of malformed
+ * input.
+ */
+class CustomScalarsTest extends AutogeneratedTestCase {
+ /**
+ * @testdox the custom scalar serializes a DateTimeImmutable to its ATOM representation.
+ */
+ public function test_serialize_returns_atom_string(): void {
+ $dt = new \DateTimeImmutable( '2024-06-15T08:30:00+00:00' );
+
+ $serialized = DummyDateTimeType::get()->serialize( $dt );
+
+ $this->assertSame( '2024-06-15T08:30:00+00:00', $serialized );
+ }
+
+ /**
+ * @testdox the custom scalar's parseValue accepts a valid ISO 8601 string.
+ */
+ public function test_parse_value_accepts_iso_8601(): void {
+ $parsed = DummyDateTimeType::get()->parseValue( '2024-06-15T08:30:00+00:00' );
+
+ $this->assertInstanceOf( \DateTimeImmutable::class, $parsed );
+ $this->assertSame( '2024-06-15T08:30:00+00:00', $parsed->format( \DateTimeInterface::ATOM ) );
+ }
+
+ /**
+ * @testdox the custom scalar's parseValue throws a GraphQL Error on malformed input.
+ */
+ public function test_parse_value_rejects_malformed_input(): void {
+ $this->expectException( \Automattic\WooCommerce\Internal\Api\Schema\Error::class );
+ DummyDateTimeType::get()->parseValue( 'not-a-date' );
+ }
+
+ /**
+ * @testdox the custom scalar's parseLiteral accepts a StringValueNode.
+ */
+ public function test_parse_literal_accepts_string_value_node(): void {
+ $node = new StringValueNode( array( 'value' => '2024-06-15T08:30:00+00:00' ) );
+ $parsed = DummyDateTimeType::get()->parseLiteral( $node );
+
+ $this->assertInstanceOf( \DateTimeImmutable::class, $parsed );
+ $this->assertSame( '2024-06-15T08:30:00+00:00', $parsed->format( \DateTimeInterface::ATOM ) );
+ }
+
+ /**
+ * @testdox the custom scalar's parseLiteral rejects non-string AST nodes.
+ */
+ public function test_parse_literal_rejects_non_string_node(): void {
+ $node = new IntValueNode( array( 'value' => '123' ) );
+
+ $this->expectException( \Automattic\WooCommerce\Internal\Api\Schema\Error::class );
+ DummyDateTimeType::get()->parseLiteral( $node );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/EnumsAndInterfacesTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/EnumsAndInterfacesTest.php
new file mode 100644
index 00000000000..b885eeb4f5a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/EnumsAndInterfacesTest.php
@@ -0,0 +1,86 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Color as ColorEnum;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Priority as PriorityEnum;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Enums\Color as ColorEnumType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Interfaces\Identifiable as IdentifiableInterfaceType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Interfaces\Named as NamedInterfaceType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Gadget as GadgetType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget as WidgetType;
+
+/**
+ * Tests for the enum and interface generation paths.
+ */
+class EnumsAndInterfacesTest extends AutogeneratedTestCase {
+ /**
+ * @testdox enums emit one EnumType value per backing case in SCREAMING_SNAKE_CASE.
+ */
+ public function test_enum_emits_value_per_case(): void {
+ $values = ColorEnumType::get()->getValues();
+ $names = array_map( static fn( $v ) => $v->name, $values );
+
+ $this->assertContains( 'RED', $names );
+ $this->assertContains( 'GREEN', $names );
+ $this->assertContains( 'BLUE', $names );
+ }
+
+ /**
+ * @testdox an enum value's PHP value equals the underlying enum case (not just the backing scalar).
+ */
+ public function test_enum_value_is_php_enum_case(): void {
+ $this->assertSame( ColorEnum::Red, ColorEnumType::get()->getValue( 'RED' )->value );
+ $this->assertSame( ColorEnum::Blue, ColorEnumType::get()->getValue( 'BLUE' )->value );
+ $this->assertSame( PriorityEnum::High, \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Enums\Priority::get()->getValue( 'HIGH' )->value );
+ }
+
+ /**
+ * @testdox an interface trait becomes an InterfaceType with its declared fields.
+ */
+ public function test_interface_emits_fields_from_trait_properties(): void {
+ $identifiable = IdentifiableInterfaceType::get();
+ // Identifiable carries #[Name('HasId')] so its GraphQL name is renamed.
+ $this->assertSame( 'HasId', $identifiable->name );
+
+ $id_field = $identifiable->getField( 'id' );
+ $this->assertSame( 'The unique numeric identifier', $id_field->description );
+ }
+
+ /**
+ * @testdox interfaces compose: Named uses Identifiable, so Named exposes both fields.
+ */
+ public function test_interface_composition_includes_inherited_fields(): void {
+ $named = NamedInterfaceType::get();
+ $this->assertNotNull( $named->getField( 'id' ) );
+ $this->assertNotNull( $named->getField( 'label' ) );
+ }
+
+ /**
+ * @testdox an output type implements the interfaces of its trait stack.
+ */
+ public function test_output_types_implement_their_trait_interfaces(): void {
+ $widget_iface_names = array_map( static fn( $i ) => $i->name, WidgetType::get()->getInterfaces() );
+ $this->assertContains( 'Named', $widget_iface_names );
+
+ $gadget_iface_names = array_map( static fn( $i ) => $i->name, GadgetType::get()->getInterfaces() );
+ $this->assertContains( 'Named', $gadget_iface_names );
+ }
+
+ /**
+ * @testdox the interface resolveType callback maps PHP classes to GraphQL types.
+ */
+ public function test_interface_resolve_type_dispatches_correctly(): void {
+ $widget_query = $this->execute_query(
+ '{ namedThing(kind: "widget") { __typename id } }'
+ );
+ $gadget_query = $this->execute_query(
+ '{ namedThing(kind: "gadget") { __typename id } }'
+ );
+
+ $this->assertSame( 'Widget', $widget_query['data']['namedThing']['__typename'] ?? null );
+ $this->assertSame( 'GadgetType', $gadget_query['data']['namedThing']['__typename'] ?? null );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MutationExecutionTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MutationExecutionTest.php
new file mode 100644
index 00000000000..11fbf815436
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MutationExecutionTest.php
@@ -0,0 +1,120 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;
+
+/**
+ * Tests that the generated mutation resolvers — input-type conversion,
+ * scalar-return wrapping, and capability enforcement — behave end-to-end.
+ */
+class MutationExecutionTest extends AutogeneratedTestCase {
+ /**
+ * @testdox createWidget converts the input payload to a PHP object and persists.
+ */
+ public function test_create_widget_persists_through_input_conversion(): void {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $query = 'mutation Create($input: CreateWidgetInput!) { createWidget(input: $input) { id label color tag_ids } }';
+ $vars = array(
+ 'input' => array(
+ 'label' => 'Gamma',
+ 'color' => 'GREEN',
+ 'tag_ids' => array( 7, 8 ),
+ ),
+ );
+
+ $result = $this->execute_query( $query, $vars );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $created = $result['data']['createWidget'] ?? null;
+ $this->assertIsArray( $created );
+ $this->assertSame( 'Gamma', $created['label'] );
+ $this->assertSame( 'GREEN', $created['color'] );
+ $this->assertSame( array( 7, 8 ), $created['tag_ids'] );
+ }
+
+ /**
+ * @testdox createWidget rejects unauthenticated callers with UNAUTHORIZED.
+ */
+ public function test_create_widget_rejects_unauthenticated_callers(): void {
+ wp_set_current_user( 0 );
+
+ $query = 'mutation Create($input: CreateWidgetInput!) { createWidget(input: $input) { id } }';
+ $vars = array(
+ 'input' => array(
+ 'label' => 'Should Not Persist',
+ 'color' => 'RED',
+ ),
+ );
+
+ $result = $this->execute_query( $query, $vars );
+
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertSame( 'UNAUTHORIZED', $result['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox deleteWidget removes a widget from the store.
+ */
+ public function test_delete_widget_removes_from_store(): void {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ DummyStore::seed();
+ $this->assertNotNull( DummyStore::get_widget( 1 ) );
+
+ $result = $this->execute_query( '{ widget(id: 1) { id } }' );
+ $this->assertSame( 1, $result['data']['widget']['id'] ?? null );
+
+ $mutation = 'mutation { deleteWidget(id: 1) { success message } }';
+ $result = $this->execute_query( $mutation );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $this->assertTrue( $result['data']['deleteWidget']['success'] ?? false );
+ $this->assertSame( 'Deleted widget 1.', $result['data']['deleteWidget']['message'] ?? null );
+
+ // Re-querying returns null since the widget no longer exists.
+ $followup = $this->execute_query( '{ widget(id: 1) { id } }' );
+ $this->assertArrayHasKey( 'data', $followup );
+ $this->assertArrayHasKey( 'widget', $followup['data'] );
+ $this->assertNull( $followup['data']['widget'] );
+ }
+
+ /**
+ * @testdox deleteWidget honours the `force` argument when the widget is missing.
+ */
+ public function test_delete_widget_honours_force_for_missing_widget(): void {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $mutation = 'mutation { deleteWidget(id: 9999, force: true) { success message } }';
+ $result = $this->execute_query( $mutation );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $this->assertTrue( $result['data']['deleteWidget']['success'] ?? false );
+ }
+
+ /**
+ * @testdox a scalar-returning mutation wraps the result in a `result` field.
+ */
+ public function test_scalar_return_mutation_wraps_in_result(): void {
+ $result = $this->execute_query( 'mutation { increment(value: 10, by: 5) { result } }' );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $this->assertSame( 15, $result['data']['increment']['result'] ?? null );
+ }
+
+ /**
+ * @testdox the scalar-return wrapper applies the parameter default when omitted.
+ */
+ public function test_scalar_return_mutation_uses_parameter_defaults(): void {
+ $result = $this->execute_query( 'mutation { increment(value: 41) { result } }' );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $this->assertSame( 42, $result['data']['increment']['result'] ?? null );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/PaginationTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/PaginationTest.php
new file mode 100644
index 00000000000..8bf086d75f7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/PaginationTest.php
@@ -0,0 +1,125 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Color;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;
+
+/**
+ * Tests for the pagination plumbing emitted around #[ConnectionOf]:
+ * connection / edge types, cursor / page-info propagation, and the unrolled
+ * PaginationParams / WidgetFilterInput parameters.
+ */
+class PaginationTest extends AutogeneratedTestCase {
+ /**
+ * Run a `widgets` query as an administrator (the resolver requires both
+ * manage_options and edit_posts).
+ *
+ * @param string $query Raw GraphQL query string.
+ *
+ * @return array{data?: ?array, errors?: array}
+ */
+ private function run_as_admin( string $query ): array {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+ return $this->execute_query( $query );
+ }
+
+ /**
+ * @testdox a connection field returns edges, nodes, page_info and total_count.
+ */
+ public function test_connection_returns_edges_nodes_pageinfo_total_count(): void {
+ DummyStore::seed();
+
+ $result = $this->run_as_admin(
+ '{ widgets { edges { cursor node { id label } } nodes { id } page_info { has_next_page start_cursor end_cursor } total_count } }'
+ );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $widgets = $result['data']['widgets'] ?? null;
+ $this->assertIsArray( $widgets );
+
+ $this->assertArrayHasKey( 'edges', $widgets );
+ $this->assertArrayHasKey( 'nodes', $widgets );
+ $this->assertArrayHasKey( 'page_info', $widgets );
+ $this->assertArrayHasKey( 'total_count', $widgets );
+
+ $this->assertCount( 2, $widgets['edges'] );
+ $this->assertSame( 1, $widgets['edges'][0]['node']['id'] ?? null );
+ $this->assertSame( 'Alpha', $widgets['edges'][0]['node']['label'] ?? null );
+ $this->assertSame( 2, $widgets['total_count'] );
+ }
+
+ /**
+ * @testdox the unrolled WidgetFilterInput parameter exposes its public properties as args.
+ */
+ public function test_unrolled_filter_exposes_individual_args(): void {
+ DummyStore::seed();
+ DummyStore::create_widget( 'Crimson', Color::Red, 'crimson' );
+
+ $result = $this->run_as_admin( '{ widgets(color: RED) { nodes { id label color } total_count } }' );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $nodes = $result['data']['widgets']['nodes'] ?? null;
+ $this->assertIsArray( $nodes );
+ foreach ( $nodes as $node ) {
+ $this->assertSame( 'RED', $node['color'] );
+ }
+ }
+
+ /**
+ * @testdox the search filter is applied through the unrolled input.
+ */
+ public function test_unrolled_filter_search_term(): void {
+ DummyStore::seed();
+
+ $result = $this->run_as_admin( '{ widgets(search: "alpha") { nodes { label } total_count } }' );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $labels = array_column( $result['data']['widgets']['nodes'] ?? array(), 'label' );
+ $this->assertContains( 'Alpha', $labels );
+ $this->assertNotContains( 'Beta', $labels );
+ }
+
+ /**
+ * @testdox PaginationParams arguments are unrolled at the schema level.
+ */
+ public function test_pagination_args_are_unrolled(): void {
+ DummyStore::seed();
+
+ $result = $this->run_as_admin( '{ widgets(first: 1) { nodes { id } total_count } }' );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $this->assertCount( 1, $result['data']['widgets']['nodes'] ?? array() );
+ $this->assertSame( 2, $result['data']['widgets']['total_count'] ?? null );
+ }
+
+ /**
+ * @testdox a negative `first` value is rejected with INVALID_ARGUMENT.
+ */
+ public function test_negative_first_is_rejected(): void {
+ DummyStore::seed();
+
+ $result = $this->run_as_admin( '{ widgets(first: -1) { nodes { id } } }' );
+
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertSame( 'INVALID_ARGUMENT', $result['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox a connection-typed property on an output type wires up the resolver.
+ */
+ public function test_connection_property_resolves_with_pagination_data(): void {
+ DummyStore::seed();
+
+ $result = $this->run_as_admin(
+ '{ widget(id: 1) { id reviews { total_count nodes { id body } page_info { start_cursor } } } }'
+ );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $this->assertSame( 2, $result['data']['widget']['reviews']['total_count'] ?? null );
+ $this->assertCount( 2, $result['data']['widget']['reviews']['nodes'] ?? array() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/QueryExecutionTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/QueryExecutionTest.php
new file mode 100644
index 00000000000..a208dc06518
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/QueryExecutionTest.php
@@ -0,0 +1,158 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container as DummyContainer;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\GetWidget;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;
+
+/**
+ * Tests that the generated query resolvers dispatch correctly against the
+ * dummy fixture API and propagate values through to the GraphQL response.
+ */
+class QueryExecutionTest extends AutogeneratedTestCase {
+ /**
+ * @testdox a public query without parameters returns the resolver's value.
+ */
+ public function test_public_query_returns_resolver_value(): void {
+ $result = $this->execute_query( '{ greeting { result } }' );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $this->assertSame( 'Hello, world!', $result['data']['greeting']['result'] ?? null );
+ }
+
+ /**
+ * @testdox a public query forwards optional arguments to the command.
+ */
+ public function test_public_query_forwards_arguments(): void {
+ $result = $this->execute_query( '{ greeting(name: "Alice") { result } }' );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $this->assertSame( 'Hello, Alice!', $result['data']['greeting']['result'] ?? null );
+ }
+
+ /**
+ * @testdox a capability-protected query succeeds when the cap is held.
+ */
+ public function test_protected_query_succeeds_when_authorized(): void {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+ DummyStore::seed();
+
+ $result = $this->execute_query( '{ widget(id: 1) { id label } }' );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $this->assertSame( 1, $result['data']['widget']['id'] ?? null );
+ $this->assertSame( 'Alpha', $result['data']['widget']['label'] ?? null );
+ }
+
+ /**
+ * @testdox the resolver routes through the configured Container.
+ */
+ public function test_resolver_routes_through_container(): void {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $override = new class() extends GetWidget {
+ /**
+ * Always returns a sentinel widget.
+ *
+ * @param int $id The widget id (unused).
+ */
+ public function execute( int $id ): ?\Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Widget {
+ unset( $id );
+ $widget = new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Widget();
+ $widget->id = 999;
+ $widget->label = 'OVERRIDDEN';
+ $widget->slug = 'overridden';
+ $widget->caption = null;
+ $widget->color = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Color::Blue;
+ $widget->priority = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Priority::Normal;
+ $widget->tag_ids = array();
+ $widget->featured_reviews = array();
+ $reviews = new \Automattic\WooCommerce\Api\Pagination\Connection();
+ $reviews->edges = array();
+ $reviews->nodes = array();
+ $reviews->page_info = new \Automattic\WooCommerce\Api\Pagination\PageInfo();
+ $reviews->page_info->has_next_page = false;
+ $reviews->page_info->has_previous_page = false;
+ $reviews->page_info->start_cursor = null;
+ $reviews->page_info->end_cursor = null;
+ $reviews->total_count = 0;
+ $widget->reviews = $reviews;
+ $widget->date_created = null;
+ $widget->price = '0';
+ $widget->legacy_price = '0';
+ return $widget;
+ }
+ };
+ DummyContainer::set_instance( GetWidget::class, $override );
+
+ $result = $this->execute_query( '{ widget(id: 1) { id label } }' );
+
+ $this->assertArrayNotHasKey( 'errors', $result );
+ $this->assertSame( 999, $result['data']['widget']['id'] ?? null );
+ $this->assertSame( 'OVERRIDDEN', $result['data']['widget']['label'] ?? null );
+ }
+
+ /**
+ * @testdox a query that throws InvalidArgumentException surfaces an INVALID_ARGUMENT error.
+ */
+ public function test_invalid_argument_exception_translates_to_invalid_argument_code(): void {
+ $result = $this->execute_query( '{ failing(kind: "invalid_argument") { result } }' );
+
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertSame( 'INVALID_ARGUMENT', $result['errors'][0]['extensions']['code'] ?? null );
+ $this->assertSame( 'Bad input from caller.', $result['errors'][0]['message'] ?? null );
+ }
+
+ /**
+ * @testdox a query that throws ApiException carries its custom code through.
+ */
+ public function test_api_exception_carries_custom_code(): void {
+ $result = $this->execute_query( '{ failing(kind: "api_exception") { result } }' );
+
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertSame( 'CUSTOM_FAILURE', $result['errors'][0]['extensions']['code'] ?? null );
+ $this->assertSame( 'Custom failure.', $result['errors'][0]['message'] ?? null );
+ $this->assertSame( 'extra', $result['errors'][0]['extensions']['detail'] ?? null );
+ }
+
+ /**
+ * @testdox a query that throws an unexpected error is masked behind INTERNAL_ERROR.
+ */
+ public function test_unexpected_throwable_masks_internal_error(): void {
+ $result = $this->execute_query( '{ failing(kind: "runtime") { result } }' );
+
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertSame( 'INTERNAL_ERROR', $result['errors'][0]['extensions']['code'] ?? null );
+ // The wrapping GraphQLError carries 'An unexpected error occurred.', but
+ // because its `previous` is a non-ClientAware throwable webonyx replaces
+ // the message with its generic 'Internal server error' on the wire.
+ // Either is acceptable here — the contract is that the *resolver-side*
+ // message does not leak.
+ $this->assertNotEquals( 'leaky internals', $result['errors'][0]['message'] ?? null );
+ }
+
+ /**
+ * @testdox a query returning an interface dispatches via resolveType.
+ */
+ public function test_interface_return_dispatches_to_concrete_type(): void {
+ $widget_result = $this->execute_query(
+ '{ namedThing(kind: "widget") { ... on Widget { id label slug } } }'
+ );
+ $gadget_result = $this->execute_query(
+ '{ namedThing(kind: "gadget") { ... on GadgetType { id label parts_count } } }'
+ );
+
+ $this->assertArrayNotHasKey( 'errors', $widget_result );
+ $this->assertSame( 'Alpha', $widget_result['data']['namedThing']['label'] ?? null );
+ $this->assertSame( 'alpha', $widget_result['data']['namedThing']['slug'] ?? null );
+
+ $this->assertArrayNotHasKey( 'errors', $gadget_result );
+ $this->assertSame( 'Sample Gadget', $gadget_result['data']['namedThing']['label'] ?? null );
+ $this->assertSame( 7, $gadget_result['data']['namedThing']['parts_count'] ?? null );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/SchemaShapeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/SchemaShapeTest.php
new file mode 100644
index 00000000000..bebb371f48b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/SchemaShapeTest.php
@@ -0,0 +1,124 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLController as DummyGraphQLController;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Enums\Color as ColorEnumType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Enums\Priority as PriorityEnumType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Input\CreateWidget as CreateWidgetInputType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Interfaces\Identifiable as IdentifiableInterfaceType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Interfaces\Named as NamedInterfaceType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Gadget as GadgetType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget as WidgetType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Pagination\WidgetConnection as WidgetConnectionType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Scalars\DummyDateTime as DummyDateTimeType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\RootMutationType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\RootQueryType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\TypeRegistry;
+use Automattic\WooCommerce\Internal\Api\Schema\CustomScalarType;
+use Automattic\WooCommerce\Internal\Api\Schema\EnumType;
+use Automattic\WooCommerce\Internal\Api\Schema\InputObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\InterfaceType;
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+
+/**
+ * Top-level shape assertions on the autogenerated dummy schema. These verify
+ * that the builder emits the expected named types in the expected categories
+ * (object / input / interface / enum / scalar / connection / edge).
+ */
+class SchemaShapeTest extends AutogeneratedTestCase {
+ /**
+ * @testdox the autogenerated GraphQLController extends the abstract base controller.
+ */
+ public function test_generated_controller_extends_base(): void {
+ $this->assertTrue(
+ is_subclass_of( DummyGraphQLController::class, \Automattic\WooCommerce\Internal\Api\GraphQLController::class )
+ );
+ }
+
+ /**
+ * @testdox RootQueryType exposes every non-ignored query and skips ignored ones.
+ */
+ public function test_root_query_lists_every_non_ignored_query(): void {
+ $root_query = RootQueryType::get();
+
+ $this->assertInstanceOf( ObjectType::class, $root_query );
+ $this->assertSame( 'Query', $root_query->name );
+
+ $fields = $root_query->getFields();
+ $this->assertArrayHasKey( 'widget', $fields );
+ $this->assertArrayHasKey( 'widgets', $fields );
+ $this->assertArrayHasKey( 'greeting', $fields );
+ $this->assertArrayHasKey( 'namedThing', $fields );
+ $this->assertArrayHasKey( 'failing', $fields );
+ // IgnoredQuery has #[Ignore] and must not show up.
+ $this->assertArrayNotHasKey( 'ignoredQuery', $fields );
+ $this->assertArrayNotHasKey( 'IgnoredQuery', $fields );
+ }
+
+ /**
+ * @testdox RootMutationType exposes every mutation and uses the right field names.
+ */
+ public function test_root_mutation_lists_every_mutation(): void {
+ $root_mutation = RootMutationType::get();
+
+ $this->assertSame( 'Mutation', $root_mutation->name );
+
+ $fields = $root_mutation->getFields();
+ $this->assertArrayHasKey( 'createWidget', $fields );
+ $this->assertArrayHasKey( 'deleteWidget', $fields );
+ $this->assertArrayHasKey( 'increment', $fields );
+ }
+
+ /**
+ * @testdox TypeRegistry returns every concrete output type that implements an interface.
+ */
+ public function test_type_registry_returns_implementors(): void {
+ $types = TypeRegistry::get_interface_implementors();
+ $names = array_map(
+ static fn( $t ) => $t->name,
+ $types
+ );
+
+ $this->assertContains( 'Widget', $names );
+ $this->assertContains( 'GadgetType', $names );
+ $this->assertContains( 'WidgetReview', $names );
+ }
+
+ /**
+ * @testdox each generated category emits a singleton of the right type.
+ */
+ public function test_generated_categories_have_correct_runtime_types(): void {
+ $this->assertInstanceOf( ObjectType::class, WidgetType::get() );
+ $this->assertInstanceOf( ObjectType::class, GadgetType::get() );
+ $this->assertInstanceOf( ObjectType::class, WidgetConnectionType::get() );
+ $this->assertInstanceOf( EnumType::class, ColorEnumType::get() );
+ $this->assertInstanceOf( EnumType::class, PriorityEnumType::get() );
+ $this->assertInstanceOf( InterfaceType::class, IdentifiableInterfaceType::get() );
+ $this->assertInstanceOf( InterfaceType::class, NamedInterfaceType::get() );
+ $this->assertInstanceOf( InputObjectType::class, CreateWidgetInputType::get() );
+ $this->assertInstanceOf( CustomScalarType::class, DummyDateTimeType::get() );
+ }
+
+ /**
+ * @testdox the generated singletons are stable — get() returns the same instance every call.
+ */
+ public function test_generated_singletons_are_stable(): void {
+ $this->assertSame( WidgetType::get(), WidgetType::get() );
+ $this->assertSame( ColorEnumType::get(), ColorEnumType::get() );
+ $this->assertSame( DummyDateTimeType::get(), DummyDateTimeType::get() );
+ }
+
+ /**
+ * @testdox the dummy schema introspects without errors.
+ */
+ public function test_schema_assertion_passes(): void {
+ // Schema::assertValid() throws on internal inconsistencies (missing
+ // implementors, mismatched interface fields, etc.). If anything in
+ // the generated tree is malformed this throws.
+ $this->build_schema()->assertValid();
+ $this->assertTrue( true );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Authorization/PublicAccessTrait.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Authorization/PublicAccessTrait.php
new file mode 100644
index 00000000000..4c4abbac74f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Authorization/PublicAccessTrait.php
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Authorization;
+
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+
+/**
+ * Trait carrying #[PublicAccess]; queries that `use` it inherit public
+ * access without having to declare the attribute themselves.
+ */
+#[PublicAccess]
+trait PublicAccessTrait {
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Authorization/RequiresEditPostsTrait.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Authorization/RequiresEditPostsTrait.php
new file mode 100644
index 00000000000..b09076f78d3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Authorization/RequiresEditPostsTrait.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Authorization;
+
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+
+/**
+ * Trait carrying #[RequiredCapability('edit_posts')]. Combined with another
+ * inheritance source (parent class) on the same query class, the builder
+ * should merge the capabilities from both into the generated check list.
+ */
+#[RequiredCapability( 'edit_posts' )]
+trait RequiresEditPostsTrait {
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Authorization/RequiresManageOptions.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Authorization/RequiresManageOptions.php
new file mode 100644
index 00000000000..d4218a509c8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Authorization/RequiresManageOptions.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Authorization;
+
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+
+/**
+ * PHP interface that carries a #[RequiredCapability] attribute. Used to
+ * verify that ApiBuilder honours capability inheritance via implements clauses
+ * (in addition to parent classes and traits).
+ *
+ * Lives in the non-classified Authorization/ directory so the builder skips
+ * it during discovery — it's a helper, not a code-API concept itself.
+ */
+#[RequiredCapability( 'manage_options' )]
+interface RequiresManageOptions {
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Container.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Container.php
new file mode 100644
index 00000000000..a9786801de3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Container.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi;
+
+/**
+ * Resolver container for the dummy code-API used by the GraphQL infrastructure
+ * tests.
+ *
+ * Mirrors the public signature ApiBuilder requires: a public static
+ * `get(string): object` method. Tests can swap the underlying instances via
+ * {@see self::set_instance()} so a single resolver dispatch can be observed
+ * with a known command instance.
+ */
+final class Container {
+ /**
+ * @var array<class-string, object>
+ */
+ private static array $instances = array();
+
+ public static function set_instance( string $class_name, object $instance ): void {
+ self::$instances[ $class_name ] = $instance;
+ }
+
+ public static function reset(): void {
+ self::$instances = array();
+ }
+
+ public static function get( string $class_name ): object {
+ if ( isset( self::$instances[ $class_name ] ) ) {
+ return self::$instances[ $class_name ];
+ }
+ return new $class_name();
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Enums/Color.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Enums/Color.php
new file mode 100644
index 00000000000..2421f25439f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Enums/Color.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+#[Description( 'A simple color palette' )]
+enum Color: string {
+ #[Description( 'Red' )]
+ case Red = 'red';
+
+ #[Description( 'Green' )]
+ case Green = 'green';
+
+ case Blue = 'blue';
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Enums/Priority.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Enums/Priority.php
new file mode 100644
index 00000000000..dd13375176b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Enums/Priority.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums;
+
+use Automattic\WooCommerce\Api\Attributes\Deprecated;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+
+/**
+ * Exercises class-level #[Name] (renames the GraphQL type) and case-level
+ * #[Name] / #[Deprecated] / #[Description].
+ */
+#[Name( 'TaskPriority' )]
+#[Description( 'Priority level for a task' )]
+enum Priority: string {
+ #[Description( 'Low priority' )]
+ case Low = 'low';
+
+ #[Name( 'NORMAL_PRIORITY' )]
+ case Normal = 'normal';
+
+ #[Description( 'High priority' )]
+ #[Deprecated( 'Use NORMAL_PRIORITY instead.' )]
+ case High = 'high';
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/InputTypes/CreateWidgetInput.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/InputTypes/CreateWidgetInput.php
new file mode 100644
index 00000000000..e03f80ae64a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/InputTypes/CreateWidgetInput.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\ScalarType;
+use Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Color;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Scalars\DummyDateTime;
+
+/**
+ * Input type for creating a widget.
+ */
+#[Description( 'Data needed to create a new widget' )]
+class CreateWidgetInput {
+ use TracksProvidedFields;
+
+ #[Description( 'The widget label' )]
+ public string $label;
+
+ #[Description( 'Optional weight in grams' )]
+ public ?int $weight = null;
+
+ #[Description( 'The widget color' )]
+ public Color $color;
+
+ #[Description( 'Tag IDs to attach to the widget' )]
+ #[ArrayOf( 'int' )]
+ public ?array $tag_ids = null;
+
+ #[Description( 'When the widget should expire' )]
+ #[ScalarType( DummyDateTime::class )]
+ public ?string $expires_at = null;
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/InputTypes/WidgetFilterInput.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/InputTypes/WidgetFilterInput.php
new file mode 100644
index 00000000000..02debb0c0ed
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/InputTypes/WidgetFilterInput.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Color;
+
+/**
+ * Filter input applied to widget listings. Used as an unrolled #[Unroll]
+ * parameter on `ListWidgets::execute()` so its public properties become
+ * individual GraphQL arguments.
+ *
+ * Carries an explicit constructor with promoted parameters because the
+ * generator emits `new WidgetFilterInput(search: ..., color: ...)` for the
+ * unrolled call site.
+ */
+#[Name( 'WidgetFilterArgs' )]
+#[Description( 'Filters applied to a widget listing' )]
+class WidgetFilterInput {
+ /**
+ * Constructor.
+ *
+ * @param ?string $search A free-text search term.
+ * @param ?Color $color Filter widgets by color.
+ */
+ public function __construct(
+ #[Description( 'A free-text search term' )]
+ public ?string $search = null,
+ #[Description( 'Filter widgets by color' )]
+ public ?Color $color = null,
+ ) {
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Interfaces/Identifiable.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Interfaces/Identifiable.php
new file mode 100644
index 00000000000..77da49008b8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Interfaces/Identifiable.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Interfaces;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+
+/**
+ * Interface trait exposing a numeric identifier.
+ *
+ * Carries a class-level #[Name] override so the GraphQL interface name is
+ * `HasId`. Pairs with the un-renamed {@see Named} trait so both branches of
+ * the interface-name code path are covered.
+ */
+#[Name( 'HasId' )]
+#[Description( 'An object with a numeric identifier' )]
+trait Identifiable {
+ #[Description( 'The unique numeric identifier' )]
+ public int $id;
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Interfaces/Named.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Interfaces/Named.php
new file mode 100644
index 00000000000..7ab61b19d31
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Interfaces/Named.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Interfaces;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Interface trait that gives a type a human-readable label.
+ */
+#[Description( 'An object with a human-readable label' )]
+trait Named {
+ use Identifiable;
+
+ #[Description( 'The display label for this object' )]
+ public string $label;
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/CreateWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/CreateWidget.php
new file mode 100644
index 00000000000..e51e2095e52
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/CreateWidget.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Mutations;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\CreateWidgetInput;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Widget;
+
+/**
+ * Creates a widget — exercises:
+ * - input type to PHP class conversion in the generated resolver.
+ * - object return type.
+ * - #[RequiredCapability] enforcement.
+ */
+#[Description( 'Create a new widget' )]
+#[RequiredCapability( 'manage_options' )]
+class CreateWidget {
+ public function execute(
+ #[Description( 'The data for the new widget' )]
+ CreateWidgetInput $input,
+ ): Widget {
+ $widget = Store::create_widget( $input->label, $input->color );
+ if ( null !== $input->weight ) {
+ $widget->caption = sprintf( 'weighs %d g', $input->weight );
+ }
+ if ( $input->was_provided( 'tag_ids' ) && null !== $input->tag_ids ) {
+ $widget->tag_ids = $input->tag_ids;
+ }
+ return $widget;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/DeleteWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/DeleteWidget.php
new file mode 100644
index 00000000000..c105d24d897
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/DeleteWidget.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Mutations;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\OperationResult;
+
+#[Description( 'Delete a widget' )]
+#[RequiredCapability( 'manage_options' )]
+class DeleteWidget {
+ public function execute(
+ #[Description( 'The widget id to delete' )]
+ int $id,
+ #[Description( 'When true, ignore "not found" errors' )]
+ bool $force = false,
+ ): OperationResult {
+ $result = new OperationResult();
+ if ( Store::delete_widget( $id ) ) {
+ $result->success = true;
+ $result->message = sprintf( 'Deleted widget %d.', $id );
+ return $result;
+ }
+ $result->success = $force;
+ $result->message = $force ? 'Widget not found, force was set.' : 'Widget not found.';
+ return $result;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/Increment.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/Increment.php
new file mode 100644
index 00000000000..488b8a8542f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/Increment.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Mutations;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+
+/**
+ * Mutation that returns a scalar (int) — exercises the generator's "wrap a
+ * scalar return in a result object" path on the mutation side.
+ */
+#[Name( 'increment' )]
+#[Description( 'Increment a value by an optional amount' )]
+#[PublicAccess]
+class Increment {
+ public function execute(
+ #[Description( 'The starting value' )]
+ int $value,
+ #[Description( 'How much to add' )]
+ int $by = 1,
+ ): int {
+ return $value + $by;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/AuthorizeOnlyQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/AuthorizeOnlyQuery.php
new file mode 100644
index 00000000000..8949e9d4411
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/AuthorizeOnlyQuery.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+
+/**
+ * No #[RequiredCapability] / #[PublicAccess]; authorization is decided
+ * solely by the authorize() method, which here mirrors its `$allow` argument.
+ */
+#[Name( 'authorizeOnly' )]
+#[Description( 'Authorization decided solely by authorize()' )]
+class AuthorizeOnlyQuery {
+ public function execute( bool $allow ): string {
+ unset( $allow );
+ return 'allowed';
+ }
+
+ /**
+ * Authorize the call. Mirrors `$allow` so tests can drive both branches.
+ *
+ * @param bool $allow Whether to allow the call.
+ */
+ public function authorize( bool $allow ): bool {
+ return $allow;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/AuthorizeThrowsQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/AuthorizeThrowsQuery.php
new file mode 100644
index 00000000000..0b1aa456824
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/AuthorizeThrowsQuery.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+
+/**
+ * Authorization is decided solely by `authorize()`, which always throws. The
+ * `$kind` argument selects the exception class so tests can verify the
+ * resolver's exception-translation path for each (an `ApiException` carries
+ * its custom code through; any other `Throwable` is masked behind
+ * `INTERNAL_ERROR` with a generic message).
+ */
+#[Name( 'authorizeThrows' )]
+#[Description( 'authorize() throws to verify exception translation' )]
+class AuthorizeThrowsQuery {
+ public function execute(
+ #[Description( 'Which exception class authorize() should raise.' )]
+ string $kind,
+ ): string {
+ // Never reached — authorize() always throws.
+ unset( $kind );
+ return 'unreachable';
+ }
+
+ /**
+ * Always throws. The `$kind` argument selects the exception class.
+ *
+ * @param string $kind Exception variety to raise.
+ *
+ * @throws ApiException When `$kind === 'api_exception'`.
+ * @throws \RuntimeException Otherwise.
+ */
+ public function authorize( string $kind ): bool {
+ if ( 'api_exception' === $kind ) {
+ throw new ApiException( 'Authorize failed.', 'AUTH_FAILURE', array( 'detail' => 'extra' ), 403 );
+ }
+ throw new \RuntimeException( 'Internals leaked from authorize.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/ComposedAuthorizeQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/ComposedAuthorizeQuery.php
new file mode 100644
index 00000000000..20a4b4ee59a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/ComposedAuthorizeQuery.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+
+/**
+ * Composes a #[RequiredCapability] with a custom authorize(): the resolver
+ * passes the cap-check result as the `$_preauthorized` infrastructure
+ * argument, so this method can either short-circuit on the attribute's
+ * decision or fall back to its own logic (here: an extra cap fallback).
+ */
+#[Name( 'composedAuthorize' )]
+#[Description( 'Composes #[RequiredCapability] with authorize() via $_preauthorized' )]
+#[RequiredCapability( 'manage_options' )]
+class ComposedAuthorizeQuery {
+ public function execute(): string {
+ return 'composed';
+ }
+
+ /**
+ * Allow when the attribute already passed (preauthorized) OR when the
+ * caller has the edit_posts fallback cap.
+ *
+ * @param bool $_preauthorized True when current_user_can('manage_options') passed at the resolver level.
+ */
+ public function authorize( bool $_preauthorized ): bool {
+ return $_preauthorized || current_user_can( 'edit_posts' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/IgnoredAuthorizeQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/IgnoredAuthorizeQuery.php
new file mode 100644
index 00000000000..6c356a5fd7a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/IgnoredAuthorizeQuery.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Ignore;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+
+/**
+ * authorize() carries #[Ignore]; the builder must skip it and rely on
+ * #[RequiredCapability] alone. The authorize() body returns `false`, so if
+ * the builder *did* call it, every request would be rejected — making any
+ * regression unmistakable.
+ */
+#[Name( 'ignoredAuthorize' )]
+#[Description( 'authorize() with #[Ignore] is skipped; the cap check applies' )]
+#[RequiredCapability( 'manage_options' )]
+class IgnoredAuthorizeQuery {
+ public function execute(): string {
+ return 'cap enforced';
+ }
+
+ #[Ignore]
+ public function authorize(): bool {
+ return false;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/OverriddenAuthorizeQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/OverriddenAuthorizeQuery.php
new file mode 100644
index 00000000000..8fed8b63ace
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/OverriddenAuthorizeQuery.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance\BaseManageOptionsQuery;
+
+/**
+ * Inherits #[RequiredCapability('manage_options')] from its parent and
+ * declares its own authorize(). This is the documented override mechanism:
+ * authorize() takes precedence, the inherited cap is silently superseded
+ * (no $_preauthorized parameter, so no composition).
+ */
+#[Name( 'overriddenAuthorize' )]
+#[Description( 'authorize() supersedes the cap inherited from the parent' )]
+class OverriddenAuthorizeQuery extends BaseManageOptionsQuery {
+ public function execute(): string {
+ return 'authorize wins';
+ }
+
+ /**
+ * Allow only callers with the edit_posts capability — independent of
+ * the manage_options cap inherited from {@see BaseManageOptionsQuery}.
+ */
+ public function authorize(): bool {
+ return current_user_can( 'edit_posts' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/FailingQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/FailingQuery.php
new file mode 100644
index 00000000000..0b5854e683f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/FailingQuery.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+
+/**
+ * Always throws, used to exercise the resolver's exception → GraphQL error
+ * translation path. The argument selects which exception variety to raise.
+ */
+#[Name( 'failing' )]
+#[Description( 'Always throws an exception' )]
+#[PublicAccess]
+class FailingQuery {
+ public function execute(
+ #[Description( 'What kind of failure to raise' )]
+ string $kind = 'invalid_argument',
+ ): string {
+ switch ( $kind ) {
+ case 'api_exception':
+ throw new ApiException( 'Custom failure.', 'CUSTOM_FAILURE', array( 'detail' => 'extra' ), 418 );
+ case 'invalid_argument':
+ throw new \InvalidArgumentException( 'Bad input from caller.' );
+ case 'runtime':
+ default:
+ throw new \RuntimeException( 'Something blew up.' );
+ }
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/GetGreeting.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/GetGreeting.php
new file mode 100644
index 00000000000..a434effef65
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/GetGreeting.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+
+/**
+ * Returns a greeting — exercises:
+ * - scalar (string) return type, which the generator wraps in a result object.
+ * - #[PublicAccess].
+ */
+#[Name( 'greeting' )]
+#[Description( 'Build a greeting' )]
+#[PublicAccess]
+class GetGreeting {
+ public function execute(
+ #[Description( 'Who to greet (defaults to "world")' )]
+ ?string $name = null,
+ ): string {
+ return sprintf( 'Hello, %s!', $name ?? 'world' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/GetIdentifiable.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/GetIdentifiable.php
new file mode 100644
index 00000000000..077b70012a6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/GetIdentifiable.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Api\Attributes\ReturnType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Interfaces\Named;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store;
+
+/**
+ * Returns an interface type — exercises #[ReturnType] (since PHP cannot
+ * type-hint a trait, the method returns `object`).
+ *
+ * The argument toggles the concrete type returned so tests can verify the
+ * interface's `resolveType` callback selects the right ObjectType.
+ */
+#[Name( 'namedThing' )]
+#[Description( 'Return either a Widget or a Gadget, both of which implement Named' )]
+#[PublicAccess]
+class GetIdentifiable {
+ #[ReturnType( Named::class )]
+ public function execute(
+ #[Description( 'Which kind of object to return' )]
+ string $kind,
+ ): object {
+ if ( 'gadget' === $kind ) {
+ return Store::build_gadget( 99, 'Sample Gadget', 7 );
+ }
+ Store::seed();
+ return Store::get_widget( 1 );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/GetWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/GetWidget.php
new file mode 100644
index 00000000000..bc839340ea1
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/GetWidget.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Widget;
+
+#[Name( 'widget' )]
+#[Description( 'Fetch a single widget by ID' )]
+#[RequiredCapability( 'manage_options' )]
+class GetWidget {
+ public function execute(
+ #[Description( 'The ID of the widget to fetch' )]
+ int $id,
+ ): ?Widget {
+ return Store::get_widget( $id );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/IgnoredQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/IgnoredQuery.php
new file mode 100644
index 00000000000..8e23e349406
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/IgnoredQuery.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries;
+
+use Automattic\WooCommerce\Api\Attributes\Ignore;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+
+/**
+ * Carries #[Ignore] so the ApiBuilder skips it entirely. Tests assert that
+ * the generated schema does NOT expose any field for this class.
+ */
+#[Ignore]
+#[PublicAccess]
+class IgnoredQuery {
+ public function execute(): string {
+ return 'should never be reachable';
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/BaseManageOptionsQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/BaseManageOptionsQuery.php
new file mode 100644
index 00000000000..bcc6c2bc2fd
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/BaseManageOptionsQuery.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance;
+
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+
+/**
+ * Abstract parent class carrying #[RequiredCapability('manage_options')].
+ *
+ * Auto-ignored by the builder because it is abstract, but its attribute is
+ * still discoverable via reflection on derived classes — which is the whole
+ * point of testing inheritance.
+ */
+#[RequiredCapability( 'manage_options' )]
+abstract class BaseManageOptionsQuery {
+ /**
+ * Implemented by each concrete derived query.
+ */
+ abstract public function execute(): string;
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/InheritedCapQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/InheritedCapQuery.php
new file mode 100644
index 00000000000..f25e75d4ae0
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/InheritedCapQuery.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+
+/**
+ * Inherits #[RequiredCapability('manage_options')] from its abstract parent
+ * with no direct attribute of its own.
+ */
+#[Name( 'inheritedCap' )]
+#[Description( 'Inherits manage_options from its abstract parent' )]
+class InheritedCapQuery extends BaseManageOptionsQuery {
+ public function execute(): string {
+ return 'inherited cap';
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/InheritedFromInterfaceQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/InheritedFromInterfaceQuery.php
new file mode 100644
index 00000000000..8c08c0f740a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/InheritedFromInterfaceQuery.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Authorization\RequiresManageOptions;
+
+/**
+ * Inherits #[RequiredCapability('manage_options')] from a PHP interface.
+ */
+#[Name( 'inheritedFromInterface' )]
+#[Description( 'Inherits manage_options from a PHP interface' )]
+class InheritedFromInterfaceQuery implements RequiresManageOptions {
+ public function execute(): string {
+ return 'inherited from interface';
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/InheritedPublicQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/InheritedPublicQuery.php
new file mode 100644
index 00000000000..0fa91c1c8e1
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/InheritedPublicQuery.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Authorization\PublicAccessTrait;
+
+/**
+ * Inherits #[PublicAccess] via a trait. No direct authorization attribute.
+ */
+#[Name( 'inheritedPublic' )]
+#[Description( 'Inherits PublicAccess via a trait' )]
+class InheritedPublicQuery {
+ use PublicAccessTrait;
+
+ public function execute(): string {
+ return 'inherited public';
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/MergedCapsQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/MergedCapsQuery.php
new file mode 100644
index 00000000000..768710bfcf8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/MergedCapsQuery.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Authorization\RequiresEditPostsTrait;
+
+/**
+ * Inherits caps from two sources at once: manage_options from its parent
+ * class, edit_posts from its trait. The builder should require both.
+ */
+#[Name( 'mergedCaps' )]
+#[Description( 'Merges caps from a parent class and a trait' )]
+class MergedCapsQuery extends BaseManageOptionsQuery {
+ use RequiresEditPostsTrait;
+
+ public function execute(): string {
+ return 'merged caps';
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/OverriddenCapQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/OverriddenCapQuery.php
new file mode 100644
index 00000000000..8b9473224a8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Inheritance/OverriddenCapQuery.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+
+/**
+ * Carries a direct #[RequiredCapability] that should *override* the cap
+ * inherited from its parent — only `manage_categories` should be enforced.
+ */
+#[Name( 'overriddenCap' )]
+#[Description( 'Overrides the inherited manage_options with manage_categories' )]
+#[RequiredCapability( 'manage_categories' )]
+class OverriddenCapQuery extends BaseManageOptionsQuery {
+ public function execute(): string {
+ return 'overridden cap';
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/ListWidgets.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/ListWidgets.php
new file mode 100644
index 00000000000..57ca4c0cf82
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/ListWidgets.php
@@ -0,0 +1,102 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries;
+
+use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Attributes\Unroll;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Api\Pagination\Edge;
+use Automattic\WooCommerce\Api\Pagination\PageInfo;
+use Automattic\WooCommerce\Api\Pagination\PaginationParams;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Priority;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\WidgetFilterInput;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Widget;
+
+/**
+ * Lists widgets, exercising:
+ * - class-level #[Unroll] (via PaginationParams) on a parameter.
+ * - parameter-level #[Unroll] on the filters argument.
+ * - multiple #[RequiredCapability] attributes.
+ * - the infrastructure `_query_info` parameter.
+ * - #[ConnectionOf] on the execute method.
+ */
+#[Name( 'widgets' )]
+#[Description( 'List widgets with cursor-based pagination' )]
+#[RequiredCapability( 'manage_options' )]
+#[RequiredCapability( 'edit_posts' )]
+class ListWidgets {
+ #[ConnectionOf( Widget::class )]
+ public function execute(
+ PaginationParams $pagination,
+ #[Unroll]
+ WidgetFilterInput $filters,
+ #[Description( 'A second filter applied after the unrolled ones' )]
+ ?Priority $min_priority = null,
+ ?array $_query_info = null,
+ ): Connection {
+ unset( $_query_info );
+
+ $widgets = array_values( Store::all_widgets() );
+
+ if ( null !== $filters->color ) {
+ $widgets = array_values(
+ array_filter(
+ $widgets,
+ static fn( Widget $w ): bool => $w->color === $filters->color
+ )
+ );
+ }
+ if ( null !== $filters->search ) {
+ $needle = $filters->search;
+ $widgets = array_values(
+ array_filter(
+ $widgets,
+ static fn( Widget $w ): bool => str_contains( strtolower( $w->label ), strtolower( $needle ) )
+ )
+ );
+ }
+ if ( null !== $min_priority ) {
+ $widgets = array_values(
+ array_filter(
+ $widgets,
+ static fn( Widget $w ): bool => $w->priority === $min_priority
+ )
+ );
+ }
+
+ $total = count( $widgets );
+
+ $limit = $pagination->first ?? $pagination->last ?? PaginationParams::get_default_page_size();
+ $page = array_slice( $widgets, 0, $limit );
+
+ $edges = array();
+ $nodes = array();
+ foreach ( $page as $widget ) {
+ $edge = new Edge();
+ $edge->cursor = base64_encode( (string) $widget->id );
+ $edge->node = $widget;
+ $edges[] = $edge;
+ $nodes[] = $widget;
+ }
+
+ $page_info = new PageInfo();
+ $page_info->has_next_page = count( $page ) < $total;
+ $page_info->has_previous_page = false;
+ $page_info->start_cursor = $edges[0]->cursor ?? null;
+ $page_info->end_cursor = $edges[ count( $edges ) - 1 ]->cursor ?? null;
+
+ $connection = new Connection();
+ $connection->edges = $edges;
+ $connection->nodes = $nodes;
+ $connection->page_info = $page_info;
+ $connection->total_count = $total;
+
+ return $connection;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Scalars/DummyDateTime.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Scalars/DummyDateTime.php
new file mode 100644
index 00000000000..207b854b831
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Scalars/DummyDateTime.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Scalars;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Custom scalar for ISO-8601 date/time strings used by the dummy fixture API.
+ */
+#[Description( 'An ISO 8601 encoded date/time string used by the dummy API' )]
+class DummyDateTime {
+ public static function serialize( mixed $value ): string {
+ if ( ! $value instanceof \DateTimeInterface ) {
+ throw new \InvalidArgumentException( 'DummyDateTime::serialize() expects a DateTimeInterface instance.' );
+ }
+ return $value->format( \DateTimeInterface::ATOM );
+ }
+
+ public static function parse( string $value ): \DateTimeImmutable {
+ // Reject anything that is not a strict ATOM-formatted string. PHP's
+ // free-form date parser would otherwise accept inputs like
+ // '2024-06-15 08:30:00' which the scalar's contract disallows.
+ $date = \DateTimeImmutable::createFromFormat( \DateTimeInterface::ATOM, $value );
+ if ( false === $date || $date->format( \DateTimeInterface::ATOM ) !== $value ) {
+ throw new \InvalidArgumentException( 'Invalid ISO 8601 date/time.' );
+ }
+ return $date;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Store.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Store.php
new file mode 100644
index 00000000000..34d25eb83de
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Store.php
@@ -0,0 +1,138 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi;
+
+use Automattic\WooCommerce\Api\Attributes\Ignore;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Api\Pagination\Edge;
+use Automattic\WooCommerce\Api\Pagination\PageInfo;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Color;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Priority;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Gadget;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Widget;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\WidgetReview;
+
+/**
+ * In-memory fixture data backing the dummy queries / mutations.
+ *
+ * Carries #[Ignore] so the ApiBuilder skips it during discovery — it lives
+ * inside the scanned namespace as a convenience helper, not as a code-API
+ * type.
+ */
+#[Ignore]
+final class Store {
+ /**
+ * @var array<int, Widget>
+ */
+ private static array $widgets = array();
+
+ private static int $next_id = 1;
+
+ public static function reset(): void {
+ self::$widgets = array();
+ self::$next_id = 1;
+ self::seed();
+ }
+
+ public static function seed(): void {
+ if ( empty( self::$widgets ) ) {
+ self::create_widget( 'Alpha', Color::Red, 'alpha' );
+ self::create_widget( 'Beta', Color::Green, 'beta' );
+ }
+ }
+
+ public static function create_widget( string $label, Color $color, string $slug = '' ): Widget {
+ $widget = new Widget();
+ $widget->id = self::$next_id++;
+ $widget->label = $label;
+ $widget->slug = '' === $slug ? strtolower( $label ) : $slug;
+ $widget->caption = null;
+ $widget->color = $color;
+ $widget->priority = Priority::Normal;
+ $widget->tag_ids = array( 1, 2, 3 );
+ $widget->featured_reviews = self::build_reviews( $widget->id, 1 );
+ $widget->reviews = self::build_review_connection( $widget->id, 2 );
+ $widget->date_created = '2024-01-01T00:00:00+00:00';
+ $widget->price = '9.99';
+ $widget->legacy_price = '8.50';
+ $widget->internal_notes = 'do not expose';
+
+ self::$widgets[ $widget->id ] = $widget;
+ return $widget;
+ }
+
+ public static function get_widget( int $id ): ?Widget {
+ return self::$widgets[ $id ] ?? null;
+ }
+
+ public static function delete_widget( int $id ): bool {
+ if ( ! isset( self::$widgets[ $id ] ) ) {
+ return false;
+ }
+ unset( self::$widgets[ $id ] );
+ return true;
+ }
+
+ /**
+ * @return array<int, Widget>
+ */
+ public static function all_widgets(): array {
+ return self::$widgets;
+ }
+
+ public static function build_gadget( int $id, string $label, int $parts ): Gadget {
+ $gadget = new Gadget();
+ $gadget->id = $id;
+ $gadget->label = $label;
+ $gadget->parts_count = $parts;
+ return $gadget;
+ }
+
+ /**
+ * @return WidgetReview[]
+ */
+ private static function build_reviews( int $widget_id, int $count ): array {
+ $reviews = array();
+ for ( $i = 1; $i <= $count; $i++ ) {
+ $review = new WidgetReview();
+ $review->id = $widget_id * 100 + $i;
+ $review->body = sprintf( 'Featured review %d for widget %d', $i, $widget_id );
+ $review->score = 5;
+ $reviews[] = $review;
+ }
+ return $reviews;
+ }
+
+ private static function build_review_connection( int $widget_id, int $count ): Connection {
+ $edges = array();
+ $nodes = array();
+ for ( $i = 1; $i <= $count; $i++ ) {
+ $review = new WidgetReview();
+ $review->id = $widget_id * 1000 + $i;
+ $review->body = sprintf( 'Review %d for widget %d', $i, $widget_id );
+ $review->score = 4;
+
+ $edge = new Edge();
+ $edge->cursor = base64_encode( (string) $review->id );
+ $edge->node = $review;
+
+ $edges[] = $edge;
+ $nodes[] = $review;
+ }
+
+ $page_info = new PageInfo();
+ $page_info->has_next_page = false;
+ $page_info->has_previous_page = false;
+ $page_info->start_cursor = $edges[0]->cursor ?? null;
+ $page_info->end_cursor = $edges[ count( $edges ) - 1 ]->cursor ?? null;
+
+ $connection = new Connection();
+ $connection->edges = $edges;
+ $connection->nodes = $nodes;
+ $connection->page_info = $page_info;
+ $connection->total_count = $count;
+ return $connection;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/Gadget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/Gadget.php
new file mode 100644
index 00000000000..da73bf2a952
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/Gadget.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Interfaces\Named;
+
+/**
+ * A second concrete implementation of {@see Named}, used to verify that
+ * interface dispatch (`resolveType`) works across multiple implementors.
+ *
+ * Carries a class-level #[Name] override so the GraphQL type is `GadgetType`.
+ */
+#[Name( 'GadgetType' )]
+#[Description( 'A dummy gadget that uses a class-level #[Name] override' )]
+class Gadget {
+ use Named;
+
+ #[Description( 'How many parts the gadget contains' )]
+ public int $parts_count;
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/OperationResult.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/OperationResult.php
new file mode 100644
index 00000000000..5170bdeeb9f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/OperationResult.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+#[Description( 'The result of a generic operation' )]
+class OperationResult {
+ #[Description( 'Whether the operation succeeded' )]
+ public bool $success;
+
+ #[Description( 'A human-readable status message' )]
+ public string $message;
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/Widget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/Widget.php
new file mode 100644
index 00000000000..875dedba044
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/Widget.php
@@ -0,0 +1,73 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
+use Automattic\WooCommerce\Api\Attributes\Deprecated;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Ignore;
+use Automattic\WooCommerce\Api\Attributes\Parameter;
+use Automattic\WooCommerce\Api\Attributes\ParameterDescription;
+use Automattic\WooCommerce\Api\Attributes\ScalarType;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Color;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Priority;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Interfaces\Named;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Scalars\DummyDateTime;
+
+/**
+ * A widget — exercises every attribute applicable to an output type.
+ */
+#[Description( 'A dummy widget that exercises every output-type attribute' )]
+class Widget {
+ use Named;
+
+ #[Description( 'A short slug' )]
+ public string $slug;
+
+ #[Description( 'An optional caption' )]
+ public ?string $caption;
+
+ #[Description( 'The widget color' )]
+ public Color $color;
+
+ #[Description( 'Priority assigned to this widget' )]
+ public Priority $priority;
+
+ #[Description( 'Tag IDs assigned to this widget' )]
+ #[ArrayOf( 'int' )]
+ public array $tag_ids;
+
+ #[Description( 'Notable comments left on this widget' )]
+ #[ArrayOf( WidgetReview::class )]
+ public array $featured_reviews;
+
+ #[Description( 'Reviews of the widget' )]
+ #[ConnectionOf( WidgetReview::class )]
+ public Connection $reviews;
+
+ #[Description( 'When the widget was created' )]
+ #[ScalarType( DummyDateTime::class )]
+ public ?string $date_created;
+
+ /**
+ * Demonstrates a forwarded #[Parameter] argument on a property.
+ *
+ * The matching #[ParameterDescription] is split out below to exercise
+ * that attribute's "augment without redeclaring the type" path.
+ */
+ #[Description( 'The widget price' )]
+ #[Parameter( name: 'formatted', type: 'bool', default: false )]
+ #[ParameterDescription( name: 'formatted', description: 'When true, prepend a $ sign' )]
+ public string $price;
+
+ #[Description( 'A field flagged for removal' )]
+ #[Deprecated( 'Use price instead.' )]
+ public string $legacy_price;
+
+ #[Ignore]
+ public ?string $internal_notes;
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/WidgetReview.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/WidgetReview.php
new file mode 100644
index 00000000000..b077d319819
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/WidgetReview.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Interfaces\Identifiable;
+
+/**
+ * A review of a widget.
+ */
+#[Description( 'A review left for a widget' )]
+class WidgetReview {
+ use Identifiable;
+
+ #[Description( 'The body of the review' )]
+ public string $body;
+
+ #[Description( 'A score between 0 and 5' )]
+ public int $score;
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLController.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLController.php
new file mode 100644
index 00000000000..2a4e04ceeb6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLController.php
@@ -0,0 +1,20 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated;
+
+use Automattic\WooCommerce\Internal\Api\Schema\Schema;
+
+class GraphQLController extends \Automattic\WooCommerce\Internal\Api\GraphQLController {
+ protected function build_schema(): Schema {
+ return new Schema(
+ array(
+ 'query' => RootQueryType::get(),
+ 'mutation' => RootMutationType::get(),
+ 'types' => TypeRegistry::get_interface_implementors(),
+ )
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php
new file mode 100644
index 00000000000..6c0ad6ef37b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php
@@ -0,0 +1,72 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Mutations\CreateWidget as CreateWidgetCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget as WidgetType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Input\CreateWidget as CreateWidgetInput;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class CreateWidget {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(WidgetType::get()),
+ 'description' => __( 'Create a new widget', 'woocommerce' ),
+ 'args' => array(
+ 'input' => array(
+ 'type' => Type::nonNull(CreateWidgetInput::get()),
+ 'description' => __( 'The data for the new widget', 'woocommerce' ),
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_options' );
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( CreateWidgetCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'input', $args ) ) {
+ $execute_args['input'] = self::convert_create_widget_input( $args['input'] );
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+
+ private static function convert_create_widget_input( array $data ): \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\CreateWidgetInput {
+ $input = new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\CreateWidgetInput();
+
+ if ( array_key_exists( 'label', $data ) ) {
+ $input->mark_provided( 'label' );
+ $input->label = $data['label'];
+ }
+ if ( array_key_exists( 'weight', $data ) ) {
+ $input->mark_provided( 'weight' );
+ $input->weight = $data['weight'];
+ }
+ if ( array_key_exists( 'color', $data ) ) {
+ $input->mark_provided( 'color' );
+ $input->color = $data['color'];
+ }
+ if ( array_key_exists( 'tag_ids', $data ) ) {
+ $input->mark_provided( 'tag_ids' );
+ $input->tag_ids = $data['tag_ids'];
+ }
+ if ( array_key_exists( 'expires_at', $data ) ) {
+ $input->mark_provided( 'expires_at' );
+ $input->expires_at = $data['expires_at'];
+ }
+
+ return $input;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php
new file mode 100644
index 00000000000..2a4777704d8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php
@@ -0,0 +1,52 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Mutations\DeleteWidget as DeleteWidgetCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\OperationResult as OperationResultType;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class DeleteWidget {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(OperationResultType::get()),
+ 'description' => __( 'Delete a widget', 'woocommerce' ),
+ 'args' => array(
+ 'id' => array(
+ 'type' => Type::nonNull(Type::int()),
+ 'description' => __( 'The widget id to delete', 'woocommerce' ),
+ ),
+ 'force' => array(
+ 'type' => Type::nonNull(Type::boolean()),
+ 'description' => __( 'When true, ignore \"not found\" errors', 'woocommerce' ),
+ 'defaultValue' => false,
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_options' );
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( DeleteWidgetCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'id', $args ) ) {
+ $execute_args['id'] = $args['id'];
+ }
+ if ( array_key_exists( 'force', $args ) ) {
+ $execute_args['force'] = $args['force'];
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php
new file mode 100644
index 00000000000..8d64d705ad7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php
@@ -0,0 +1,54 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Mutations\Increment as IncrementCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class Increment {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(new \Automattic\WooCommerce\Internal\Api\Schema\ObjectType(array(
+ 'name' => 'IncrementResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull(Type::int()) ),
+ ),
+ ))),
+ 'description' => __( 'Increment a value by an optional amount', 'woocommerce' ),
+ 'args' => array(
+ 'value' => array(
+ 'type' => Type::nonNull(Type::int()),
+ 'description' => __( 'The starting value', 'woocommerce' ),
+ ),
+ 'by' => array(
+ 'type' => Type::nonNull(Type::int()),
+ 'description' => __( 'How much to add', 'woocommerce' ),
+ 'defaultValue' => 1,
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( IncrementCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'value', $args ) ) {
+ $execute_args['value'] = $args['value'];
+ }
+ if ( array_key_exists( 'by', $args ) ) {
+ $execute_args['by'] = $args['by'];
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php
new file mode 100644
index 00000000000..900ff78a09c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php
@@ -0,0 +1,54 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\AuthorizeOnlyQuery as AuthorizeOnlyQueryCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class AuthorizeOnlyQuery {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(new \Automattic\WooCommerce\Internal\Api\Schema\ObjectType(array(
+ 'name' => 'AuthorizeOnlyQueryResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull(Type::string()) ),
+ ),
+ ))),
+ 'description' => __( 'Authorization decided solely by authorize()', 'woocommerce' ),
+ 'args' => array(
+ 'allow' => array(
+ 'type' => Type::nonNull(Type::boolean()),
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( AuthorizeOnlyQueryCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'allow', $args ) ) {
+ $execute_args['allow'] = $args['allow'];
+ }
+
+ if ( ! Utils::authorize_command( $command, array(
+ 'allow' => $execute_args['allow'],
+ ) ) ) {
+ throw new \Automattic\WooCommerce\Internal\Api\Schema\Error(
+ 'You do not have permission to perform this action.',
+ extensions: array( 'code' => 'UNAUTHORIZED' )
+ );
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php
new file mode 100644
index 00000000000..9b7d28c4513
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php
@@ -0,0 +1,55 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\AuthorizeThrowsQuery as AuthorizeThrowsQueryCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class AuthorizeThrowsQuery {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(new \Automattic\WooCommerce\Internal\Api\Schema\ObjectType(array(
+ 'name' => 'AuthorizeThrowsQueryResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull(Type::string()) ),
+ ),
+ ))),
+ 'description' => __( 'authorize() throws to verify exception translation', 'woocommerce' ),
+ 'args' => array(
+ 'kind' => array(
+ 'type' => Type::nonNull(Type::string()),
+ 'description' => __( 'Which exception class authorize() should raise.', 'woocommerce' ),
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( AuthorizeThrowsQueryCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'kind', $args ) ) {
+ $execute_args['kind'] = $args['kind'];
+ }
+
+ if ( ! Utils::authorize_command( $command, array(
+ 'kind' => $execute_args['kind'],
+ ) ) ) {
+ throw new \Automattic\WooCommerce\Internal\Api\Schema\Error(
+ 'You do not have permission to perform this action.',
+ extensions: array( 'code' => 'UNAUTHORIZED' )
+ );
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ComposedAuthorizeQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ComposedAuthorizeQuery.php
new file mode 100644
index 00000000000..7c91c99374f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ComposedAuthorizeQuery.php
@@ -0,0 +1,48 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\ComposedAuthorizeQuery as ComposedAuthorizeQueryCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class ComposedAuthorizeQuery {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(new \Automattic\WooCommerce\Internal\Api\Schema\ObjectType(array(
+ 'name' => 'ComposedAuthorizeQueryResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull(Type::string()) ),
+ ),
+ ))),
+ 'description' => __( 'Composes #[RequiredCapability] with authorize() via $_preauthorized', 'woocommerce' ),
+ 'args' => array(
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( ComposedAuthorizeQueryCommand::class );
+
+ $execute_args = array();
+
+ if ( ! Utils::authorize_command( $command, array(
+ '_preauthorized' => current_user_can( 'manage_options' ),
+ ) ) ) {
+ throw new \Automattic\WooCommerce\Internal\Api\Schema\Error(
+ 'You do not have permission to perform this action.',
+ extensions: array( 'code' => 'UNAUTHORIZED' )
+ );
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php
new file mode 100644
index 00000000000..96b59e2e42f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php
@@ -0,0 +1,47 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\FailingQuery as FailingQueryCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class FailingQuery {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(new \Automattic\WooCommerce\Internal\Api\Schema\ObjectType(array(
+ 'name' => 'FailingQueryResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull(Type::string()) ),
+ ),
+ ))),
+ 'description' => __( 'Always throws an exception', 'woocommerce' ),
+ 'args' => array(
+ 'kind' => array(
+ 'type' => Type::nonNull(Type::string()),
+ 'description' => __( 'What kind of failure to raise', 'woocommerce' ),
+ 'defaultValue' => 'invalid_argument',
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( FailingQueryCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'kind', $args ) ) {
+ $execute_args['kind'] = $args['kind'];
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php
new file mode 100644
index 00000000000..c04747839e8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php
@@ -0,0 +1,47 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\GetGreeting as GetGreetingCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class GetGreeting {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(new \Automattic\WooCommerce\Internal\Api\Schema\ObjectType(array(
+ 'name' => 'GetGreetingResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull(Type::string()) ),
+ ),
+ ))),
+ 'description' => __( 'Build a greeting', 'woocommerce' ),
+ 'args' => array(
+ 'name' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'Who to greet (defaults to \"world\")', 'woocommerce' ),
+ 'defaultValue' => NULL,
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( GetGreetingCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'name', $args ) ) {
+ $execute_args['name'] = $args['name'];
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php
new file mode 100644
index 00000000000..863cee24529
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php
@@ -0,0 +1,42 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\GetIdentifiable as GetIdentifiableCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Interfaces\Named as NamedInterface;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class GetIdentifiable {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(NamedInterface::get()),
+ 'description' => __( 'Return either a Widget or a Gadget, both of which implement Named', 'woocommerce' ),
+ 'args' => array(
+ 'kind' => array(
+ 'type' => Type::nonNull(Type::string()),
+ 'description' => __( 'Which kind of object to return', 'woocommerce' ),
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( GetIdentifiableCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'kind', $args ) ) {
+ $execute_args['kind'] = $args['kind'];
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php
new file mode 100644
index 00000000000..bede69da448
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php
@@ -0,0 +1,44 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\GetWidget as GetWidgetCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget as WidgetType;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class GetWidget {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => WidgetType::get(),
+ 'description' => __( 'Fetch a single widget by ID', 'woocommerce' ),
+ 'args' => array(
+ 'id' => array(
+ 'type' => Type::nonNull(Type::int()),
+ 'description' => __( 'The ID of the widget to fetch', 'woocommerce' ),
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_options' );
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( GetWidgetCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'id', $args ) ) {
+ $execute_args['id'] = $args['id'];
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/IgnoredAuthorizeQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/IgnoredAuthorizeQuery.php
new file mode 100644
index 00000000000..5cd388528a8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/IgnoredAuthorizeQuery.php
@@ -0,0 +1,41 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\IgnoredAuthorizeQuery as IgnoredAuthorizeQueryCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class IgnoredAuthorizeQuery {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(new \Automattic\WooCommerce\Internal\Api\Schema\ObjectType(array(
+ 'name' => 'IgnoredAuthorizeQueryResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull(Type::string()) ),
+ ),
+ ))),
+ 'description' => __( 'authorize() with #[Ignore] is skipped; the cap check applies', 'woocommerce' ),
+ 'args' => array(
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_options' );
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( IgnoredAuthorizeQueryCommand::class );
+
+ $execute_args = array();
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedCapQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedCapQuery.php
new file mode 100644
index 00000000000..31ffe499fc8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedCapQuery.php
@@ -0,0 +1,41 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance\InheritedCapQuery as InheritedCapQueryCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class InheritedCapQuery {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(new \Automattic\WooCommerce\Internal\Api\Schema\ObjectType(array(
+ 'name' => 'InheritedCapQueryResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull(Type::string()) ),
+ ),
+ ))),
+ 'description' => __( 'Inherits manage_options from its abstract parent', 'woocommerce' ),
+ 'args' => array(
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_options' );
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( InheritedCapQueryCommand::class );
+
+ $execute_args = array();
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedFromInterfaceQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedFromInterfaceQuery.php
new file mode 100644
index 00000000000..2e4c8fb8aed
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedFromInterfaceQuery.php
@@ -0,0 +1,41 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance\InheritedFromInterfaceQuery as InheritedFromInterfaceQueryCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class InheritedFromInterfaceQuery {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(new \Automattic\WooCommerce\Internal\Api\Schema\ObjectType(array(
+ 'name' => 'InheritedFromInterfaceQueryResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull(Type::string()) ),
+ ),
+ ))),
+ 'description' => __( 'Inherits manage_options from a PHP interface', 'woocommerce' ),
+ 'args' => array(
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_options' );
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( InheritedFromInterfaceQueryCommand::class );
+
+ $execute_args = array();
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedPublicQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedPublicQuery.php
new file mode 100644
index 00000000000..341b94904b4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedPublicQuery.php
@@ -0,0 +1,39 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance\InheritedPublicQuery as InheritedPublicQueryCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class InheritedPublicQuery {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(new \Automattic\WooCommerce\Internal\Api\Schema\ObjectType(array(
+ 'name' => 'InheritedPublicQueryResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull(Type::string()) ),
+ ),
+ ))),
+ 'description' => __( 'Inherits PublicAccess via a trait', 'woocommerce' ),
+ 'args' => array(
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( InheritedPublicQueryCommand::class );
+
+ $execute_args = array();
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php
new file mode 100644
index 00000000000..efb4f2246df
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php
@@ -0,0 +1,87 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\ListWidgets as ListWidgetsCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Pagination\WidgetConnection as WidgetConnectionType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Enums\Color as ColorType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Enums\Priority as PriorityType;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class ListWidgets {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(WidgetConnectionType::get()),
+ 'description' => __( 'List widgets with cursor-based pagination', 'woocommerce' ),
+ 'args' => array(
+ 'first' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'Return the first N results. Must be between 0 and 100.', 'woocommerce' ),
+ 'defaultValue' => NULL,
+ ),
+ 'last' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'Return the last N results. Must be between 0 and 100.', 'woocommerce' ),
+ 'defaultValue' => NULL,
+ ),
+ 'after' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'Return results after this cursor.', 'woocommerce' ),
+ 'defaultValue' => NULL,
+ ),
+ 'before' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'Return results before this cursor.', 'woocommerce' ),
+ 'defaultValue' => NULL,
+ ),
+ 'search' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'A free-text search term', 'woocommerce' ),
+ 'defaultValue' => NULL,
+ ),
+ 'color' => array(
+ 'type' => ColorType::get(),
+ 'description' => __( 'Filter widgets by color', 'woocommerce' ),
+ 'defaultValue' => NULL,
+ ),
+ 'min_priority' => array(
+ 'type' => PriorityType::get(),
+ 'description' => __( 'A second filter applied after the unrolled ones', 'woocommerce' ),
+ 'defaultValue' => NULL,
+ ),
+ ),
+ 'complexity' => Utils::complexity_from_pagination(...),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_options' );
+ Utils::check_current_user_can( 'edit_posts' );
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( ListWidgetsCommand::class );
+
+ $execute_args = array();
+ $execute_args['pagination'] = Utils::create_pagination_params( $args );
+ $execute_args['filters'] = Utils::create_input(
+ fn() => new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\WidgetFilterInput(
+ search: $args['search'] ?? NULL,
+ color: $args['color'],
+ )
+ );
+ if ( array_key_exists( 'min_priority', $args ) ) {
+ $execute_args['min_priority'] = $args['min_priority'];
+ }
+ $execute_args['_query_info'] = QueryInfoExtractor::extract_from_info( $info, $args );
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MergedCapsQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MergedCapsQuery.php
new file mode 100644
index 00000000000..91dcbcde4fe
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MergedCapsQuery.php
@@ -0,0 +1,42 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance\MergedCapsQuery as MergedCapsQueryCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class MergedCapsQuery {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(new \Automattic\WooCommerce\Internal\Api\Schema\ObjectType(array(
+ 'name' => 'MergedCapsQueryResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull(Type::string()) ),
+ ),
+ ))),
+ 'description' => __( 'Merges caps from a parent class and a trait', 'woocommerce' ),
+ 'args' => array(
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_options' );
+ Utils::check_current_user_can( 'edit_posts' );
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( MergedCapsQueryCommand::class );
+
+ $execute_args = array();
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenAuthorizeQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenAuthorizeQuery.php
new file mode 100644
index 00000000000..2d2ef0fb553
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenAuthorizeQuery.php
@@ -0,0 +1,47 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\OverriddenAuthorizeQuery as OverriddenAuthorizeQueryCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class OverriddenAuthorizeQuery {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(new \Automattic\WooCommerce\Internal\Api\Schema\ObjectType(array(
+ 'name' => 'OverriddenAuthorizeQueryResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull(Type::string()) ),
+ ),
+ ))),
+ 'description' => __( 'authorize() supersedes the cap inherited from the parent', 'woocommerce' ),
+ 'args' => array(
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( OverriddenAuthorizeQueryCommand::class );
+
+ $execute_args = array();
+
+ if ( ! Utils::authorize_command( $command, array(
+ ) ) ) {
+ throw new \Automattic\WooCommerce\Internal\Api\Schema\Error(
+ 'You do not have permission to perform this action.',
+ extensions: array( 'code' => 'UNAUTHORIZED' )
+ );
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenCapQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenCapQuery.php
new file mode 100644
index 00000000000..817339cd9c6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenCapQuery.php
@@ -0,0 +1,41 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance\OverriddenCapQuery as OverriddenCapQueryCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class OverriddenCapQuery {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(new \Automattic\WooCommerce\Internal\Api\Schema\ObjectType(array(
+ 'name' => 'OverriddenCapQueryResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull(Type::string()) ),
+ ),
+ ))),
+ 'description' => __( 'Overrides the inherited manage_options with manage_categories', 'woocommerce' ),
+ 'args' => array(
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_categories' );
+
+ $command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( OverriddenCapQueryCommand::class );
+
+ $execute_args = array();
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Color.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Color.php
new file mode 100644
index 00000000000..18975d49020
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Color.php
@@ -0,0 +1,38 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Enums;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Color as ColorEnum;
+use Automattic\WooCommerce\Internal\Api\Schema\EnumType;
+
+class Color {
+ private static ?EnumType $instance = null;
+
+ public static function get(): EnumType {
+ if ( null === self::$instance ) {
+ self::$instance = new EnumType(
+ array(
+ 'name' => 'Color',
+ 'description' => __( 'A simple color palette', 'woocommerce' ),
+ 'values' => array(
+ 'RED' => array(
+ 'value' => ColorEnum::Red,
+ 'description' => __( 'Red', 'woocommerce' ),
+ ),
+ 'GREEN' => array(
+ 'value' => ColorEnum::Green,
+ 'description' => __( 'Green', 'woocommerce' ),
+ ),
+ 'BLUE' => array(
+ 'value' => ColorEnum::Blue,
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Priority.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Priority.php
new file mode 100644
index 00000000000..20e02c61827
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Priority.php
@@ -0,0 +1,39 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Enums;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Priority as PriorityEnum;
+use Automattic\WooCommerce\Internal\Api\Schema\EnumType;
+
+class Priority {
+ private static ?EnumType $instance = null;
+
+ public static function get(): EnumType {
+ if ( null === self::$instance ) {
+ self::$instance = new EnumType(
+ array(
+ 'name' => 'TaskPriority',
+ 'description' => __( 'Priority level for a task', 'woocommerce' ),
+ 'values' => array(
+ 'LOW' => array(
+ 'value' => PriorityEnum::Low,
+ 'description' => __( 'Low priority', 'woocommerce' ),
+ ),
+ 'NORMAL_PRIORITY' => array(
+ 'value' => PriorityEnum::Normal,
+ ),
+ 'HIGH' => array(
+ 'value' => PriorityEnum::High,
+ 'description' => __( 'High priority', 'woocommerce' ),
+ 'deprecationReason' => 'Use NORMAL_PRIORITY instead.',
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/CreateWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/CreateWidget.php
new file mode 100644
index 00000000000..5bef5f2d4bf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/CreateWidget.php
@@ -0,0 +1,49 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Input;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Enums\Color as ColorType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Scalars\DummyDateTime as DummyDateTimeType;
+use Automattic\WooCommerce\Internal\Api\Schema\InputObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class CreateWidget {
+ private static ?InputObjectType $instance = null;
+
+ public static function get(): InputObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new InputObjectType(
+ array(
+ 'name' => 'CreateWidgetInput',
+ 'description' => __( 'Data needed to create a new widget', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'label' => array(
+ 'type' => Type::nonNull(Type::string()),
+ 'description' => __( 'The widget label', 'woocommerce' ),
+ ),
+ 'weight' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'Optional weight in grams', 'woocommerce' ),
+ ),
+ 'color' => array(
+ 'type' => Type::nonNull(ColorType::get()),
+ 'description' => __( 'The widget color', 'woocommerce' ),
+ ),
+ 'tag_ids' => array(
+ 'type' => Type::listOf(Type::nonNull(Type::int())),
+ 'description' => __( 'Tag IDs to attach to the widget', 'woocommerce' ),
+ ),
+ 'expires_at' => array(
+ 'type' => DummyDateTimeType::get(),
+ 'description' => __( 'When the widget should expire', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/WidgetFilter.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/WidgetFilter.php
new file mode 100644
index 00000000000..e5c1f42a1b9
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/WidgetFilter.php
@@ -0,0 +1,36 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Input;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Enums\Color as ColorType;
+use Automattic\WooCommerce\Internal\Api\Schema\InputObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class WidgetFilter {
+ private static ?InputObjectType $instance = null;
+
+ public static function get(): InputObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new InputObjectType(
+ array(
+ 'name' => 'WidgetFilterArgs',
+ 'description' => __( 'Filters applied to a widget listing', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'search' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'A free-text search term', 'woocommerce' ),
+ ),
+ 'color' => array(
+ 'type' => ColorType::get(),
+ 'description' => __( 'Filter widgets by color', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Identifiable.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Identifiable.php
new file mode 100644
index 00000000000..20b583cf83f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Identifiable.php
@@ -0,0 +1,39 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Interfaces;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\WidgetReview as WidgetReviewType;
+use Automattic\WooCommerce\Internal\Api\Schema\InterfaceType;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class Identifiable {
+ private static ?InterfaceType $instance = null;
+
+ public static function get(): InterfaceType {
+ if ( null === self::$instance ) {
+ self::$instance = new InterfaceType(
+ array(
+ 'name' => 'HasId',
+ 'description' => __( 'An object with a numeric identifier', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'id' => array(
+ 'type' => Type::nonNull(Type::int()),
+ 'description' => __( 'The unique numeric identifier', 'woocommerce' ),
+ ),
+ ),
+ 'resolveType' => function ( $value ) {
+ $class = get_class( $value );
+ $map = array(
+ 'Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\WidgetReview' => WidgetReviewType::get(),
+ );
+ return $map[ $class ] ?? null;
+ },
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Named.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Named.php
new file mode 100644
index 00000000000..890f0216b81
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Named.php
@@ -0,0 +1,45 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Interfaces;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Gadget as GadgetType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget as WidgetType;
+use Automattic\WooCommerce\Internal\Api\Schema\InterfaceType;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class Named {
+ private static ?InterfaceType $instance = null;
+
+ public static function get(): InterfaceType {
+ if ( null === self::$instance ) {
+ self::$instance = new InterfaceType(
+ array(
+ 'name' => 'Named',
+ 'description' => __( 'An object with a human-readable label', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'label' => array(
+ 'type' => Type::nonNull(Type::string()),
+ 'description' => __( 'The display label for this object', 'woocommerce' ),
+ ),
+ 'id' => array(
+ 'type' => Type::nonNull(Type::int()),
+ 'description' => __( 'The unique numeric identifier', 'woocommerce' ),
+ ),
+ ),
+ 'resolveType' => function ( $value ) {
+ $class = get_class( $value );
+ $map = array(
+ 'Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Gadget' => GadgetType::get(),
+ 'Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Widget' => WidgetType::get(),
+ );
+ return $map[ $class ] ?? null;
+ },
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Gadget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Gadget.php
new file mode 100644
index 00000000000..5dc297f3f23
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Gadget.php
@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Interfaces\Named as NamedInterface;
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class Gadget {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'GadgetType',
+ 'description' => __( 'A dummy gadget that uses a class-level #[Name] override', 'woocommerce' ),
+ 'interfaces' => fn() => array(
+ NamedInterface::get(),
+ ),
+ 'fields' => fn() => array(
+ 'parts_count' => array(
+ 'type' => Type::nonNull(Type::int()),
+ 'description' => __( 'How many parts the gadget contains', 'woocommerce' ),
+ ),
+ 'label' => array(
+ 'type' => Type::nonNull(Type::string()),
+ 'description' => __( 'The display label for this object', 'woocommerce' ),
+ ),
+ 'id' => array(
+ 'type' => Type::nonNull(Type::int()),
+ 'description' => __( 'The unique numeric identifier', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/OperationResult.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/OperationResult.php
new file mode 100644
index 00000000000..c48b7828c2f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/OperationResult.php
@@ -0,0 +1,35 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class OperationResult {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'OperationResult',
+ 'description' => __( 'The result of a generic operation', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'success' => array(
+ 'type' => Type::nonNull(Type::boolean()),
+ 'description' => __( 'Whether the operation succeeded', 'woocommerce' ),
+ ),
+ 'message' => array(
+ 'type' => Type::nonNull(Type::string()),
+ 'description' => __( 'A human-readable status message', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php
new file mode 100644
index 00000000000..ee1bf977259
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php
@@ -0,0 +1,92 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Enums\Color as ColorType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Enums\Priority as PriorityType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\WidgetReview;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Pagination\WidgetReviewConnection as WidgetReviewConnectionType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Scalars\DummyDateTime as DummyDateTimeType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Interfaces\Named as NamedInterface;
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class Widget {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'Widget',
+ 'description' => __( 'A dummy widget that exercises every output-type attribute', 'woocommerce' ),
+ 'interfaces' => fn() => array(
+ NamedInterface::get(),
+ ),
+ 'fields' => fn() => array(
+ 'slug' => array(
+ 'type' => Type::nonNull(Type::string()),
+ 'description' => __( 'A short slug', 'woocommerce' ),
+ ),
+ 'caption' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'An optional caption', 'woocommerce' ),
+ ),
+ 'color' => array(
+ 'type' => Type::nonNull(ColorType::get()),
+ 'description' => __( 'The widget color', 'woocommerce' ),
+ ),
+ 'priority' => array(
+ 'type' => Type::nonNull(PriorityType::get()),
+ 'description' => __( 'Priority assigned to this widget', 'woocommerce' ),
+ ),
+ 'tag_ids' => array(
+ 'type' => Type::nonNull(Type::listOf(Type::nonNull(Type::int()))),
+ 'description' => __( 'Tag IDs assigned to this widget', 'woocommerce' ),
+ ),
+ 'featured_reviews' => array(
+ 'type' => Type::nonNull(Type::listOf(Type::nonNull(WidgetReview::get()))),
+ 'description' => __( 'Notable comments left on this widget', 'woocommerce' ),
+ ),
+ 'reviews' => array(
+ 'type' => Type::nonNull(WidgetReviewConnectionType::get()),
+ 'description' => __( 'Reviews of the widget', 'woocommerce' ),
+ ),
+ 'date_created' => array(
+ 'type' => DummyDateTimeType::get(),
+ 'description' => __( 'When the widget was created', 'woocommerce' ),
+ ),
+ 'price' => array(
+ 'type' => Type::nonNull(Type::string()),
+ 'description' => __( 'The widget price', 'woocommerce' ),
+ 'args' => array(
+ 'formatted' => array(
+ 'type' => Type::boolean(),
+ 'defaultValue' => false,
+ 'description' => __( 'When true, prepend a $ sign', 'woocommerce' ),
+ ),
+ ),
+ ),
+ 'legacy_price' => array(
+ 'type' => Type::nonNull(Type::string()),
+ 'description' => __( 'A field flagged for removal', 'woocommerce' ),
+ 'deprecationReason' => 'Use price instead.',
+ ),
+ 'label' => array(
+ 'type' => Type::nonNull(Type::string()),
+ 'description' => __( 'The display label for this object', 'woocommerce' ),
+ ),
+ 'id' => array(
+ 'type' => Type::nonNull(Type::int()),
+ 'description' => __( 'The unique numeric identifier', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/WidgetReview.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/WidgetReview.php
new file mode 100644
index 00000000000..ea3c9cd971e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/WidgetReview.php
@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Interfaces\Identifiable as IdentifiableInterface;
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class WidgetReview {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'WidgetReview',
+ 'description' => __( 'A review left for a widget', 'woocommerce' ),
+ 'interfaces' => fn() => array(
+ IdentifiableInterface::get(),
+ ),
+ 'fields' => fn() => array(
+ 'body' => array(
+ 'type' => Type::nonNull(Type::string()),
+ 'description' => __( 'The body of the review', 'woocommerce' ),
+ ),
+ 'score' => array(
+ 'type' => Type::nonNull(Type::int()),
+ 'description' => __( 'A score between 0 and 5', 'woocommerce' ),
+ ),
+ 'id' => array(
+ 'type' => Type::nonNull(Type::int()),
+ 'description' => __( 'The unique numeric identifier', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/PageInfo.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/PageInfo.php
new file mode 100644
index 00000000000..3739cb27dee
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/PageInfo.php
@@ -0,0 +1,38 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class PageInfo {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'PageInfo',
+ 'fields' => array(
+ 'has_next_page' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ ),
+ 'has_previous_page' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ ),
+ 'start_cursor' => array(
+ 'type' => Type::string(),
+ ),
+ 'end_cursor' => array(
+ 'type' => Type::string(),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/WidgetConnection.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/WidgetConnection.php
new file mode 100644
index 00000000000..c924fa5091a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/WidgetConnection.php
@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget as WidgetType;
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class WidgetConnection {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'WidgetConnection',
+ 'description' => __( 'A connection to a list of Widget items.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'edges' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull(
+ WidgetEdge::get()
+ ) ) ),
+ ),
+ 'nodes' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull(
+ WidgetType::get()
+ ) ) ),
+ ),
+ 'page_info' => array(
+ 'type' => Type::nonNull( PageInfo::get() ),
+ ),
+ 'total_count' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/WidgetEdge.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/WidgetEdge.php
new file mode 100644
index 00000000000..a88fb098b33
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/WidgetEdge.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget as WidgetType;
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class WidgetEdge {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'WidgetEdge',
+ 'fields' => fn() => array(
+ 'cursor' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ ),
+ 'node' => array(
+ 'type' => Type::nonNull( WidgetType::get() ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/WidgetReviewConnection.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/WidgetReviewConnection.php
new file mode 100644
index 00000000000..573e158c704
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/WidgetReviewConnection.php
@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\WidgetReview as WidgetReviewType;
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class WidgetReviewConnection {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'WidgetReviewConnection',
+ 'description' => __( 'A connection to a list of WidgetReview items.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'edges' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull(
+ WidgetReviewEdge::get()
+ ) ) ),
+ ),
+ 'nodes' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull(
+ WidgetReviewType::get()
+ ) ) ),
+ ),
+ 'page_info' => array(
+ 'type' => Type::nonNull( PageInfo::get() ),
+ ),
+ 'total_count' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/WidgetReviewEdge.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/WidgetReviewEdge.php
new file mode 100644
index 00000000000..adb2ae422cc
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Pagination/WidgetReviewEdge.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\WidgetReview as WidgetReviewType;
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class WidgetReviewEdge {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'WidgetReviewEdge',
+ 'fields' => fn() => array(
+ 'cursor' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ ),
+ 'node' => array(
+ 'type' => Type::nonNull( WidgetReviewType::get() ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Scalars/DummyDateTime.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Scalars/DummyDateTime.php
new file mode 100644
index 00000000000..7b7409efee1
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Scalars/DummyDateTime.php
@@ -0,0 +1,45 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Scalars;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Scalars\DummyDateTime as DummyDateTimeScalar;
+use Automattic\WooCommerce\Internal\Api\Schema\CustomScalarType;
+
+class DummyDateTime {
+ private static ?CustomScalarType $instance = null;
+
+ public static function get(): CustomScalarType {
+ if ( null === self::$instance ) {
+ self::$instance = new CustomScalarType(
+ array(
+ 'name' => 'DummyDateTime',
+ 'description' => __( 'An ISO 8601 encoded date/time string used by the dummy API', 'woocommerce' ),
+ 'serialize' => fn( $value ) => DummyDateTimeScalar::serialize( $value ),
+ 'parseValue' => function ( $value ) {
+ try {
+ return DummyDateTimeScalar::parse( $value );
+ } catch ( \InvalidArgumentException $e ) {
+ throw new \Automattic\WooCommerce\Internal\Api\Schema\Error( $e->getMessage() );
+ }
+ },
+ 'parseLiteral' => function ( $value_node, ?array $variables = null ) {
+ if ( $value_node instanceof \Automattic\WooCommerce\Internal\Api\Schema\AST\StringValueNode ) {
+ try {
+ return DummyDateTimeScalar::parse( $value_node->value );
+ } catch ( \InvalidArgumentException $e ) {
+ throw new \Automattic\WooCommerce\Internal\Api\Schema\Error( $e->getMessage() );
+ }
+ }
+ throw new \Automattic\WooCommerce\Internal\Api\Schema\Error(
+ 'DummyDateTime must be a string, got: ' . $value_node->kind
+ );
+ },
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootMutationType.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootMutationType.php
new file mode 100644
index 00000000000..317c312f906
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootMutationType.php
@@ -0,0 +1,31 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations\Increment;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations\CreateWidget;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations\DeleteWidget;
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+
+class RootMutationType {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'Mutation',
+ 'fields' => fn() => array(
+ 'increment' => Increment::get_field_definition(),
+ 'createWidget' => CreateWidget::get_field_definition(),
+ 'deleteWidget' => DeleteWidget::get_field_definition(),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php
new file mode 100644
index 00000000000..6015e141949
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php
@@ -0,0 +1,55 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\GetIdentifiable;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\InheritedCapQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\InheritedFromInterfaceQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\OverriddenCapQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\MergedCapsQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\InheritedPublicQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\GetWidget;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\AuthorizeThrowsQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\ComposedAuthorizeQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\IgnoredAuthorizeQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\OverriddenAuthorizeQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\AuthorizeOnlyQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\FailingQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\ListWidgets;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\GetGreeting;
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+
+class RootQueryType {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'Query',
+ 'fields' => fn() => array(
+ 'namedThing' => GetIdentifiable::get_field_definition(),
+ 'inheritedCap' => InheritedCapQuery::get_field_definition(),
+ 'inheritedFromInterface' => InheritedFromInterfaceQuery::get_field_definition(),
+ 'overriddenCap' => OverriddenCapQuery::get_field_definition(),
+ 'mergedCaps' => MergedCapsQuery::get_field_definition(),
+ 'inheritedPublic' => InheritedPublicQuery::get_field_definition(),
+ 'widget' => GetWidget::get_field_definition(),
+ 'authorizeThrows' => AuthorizeThrowsQuery::get_field_definition(),
+ 'composedAuthorize' => ComposedAuthorizeQuery::get_field_definition(),
+ 'ignoredAuthorize' => IgnoredAuthorizeQuery::get_field_definition(),
+ 'overriddenAuthorize' => OverriddenAuthorizeQuery::get_field_definition(),
+ 'authorizeOnly' => AuthorizeOnlyQuery::get_field_definition(),
+ 'failing' => FailingQuery::get_field_definition(),
+ 'widgets' => ListWidgets::get_field_definition(),
+ 'greeting' => GetGreeting::get_field_definition(),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/TypeRegistry.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/TypeRegistry.php
new file mode 100644
index 00000000000..e4513c2a7d9
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/TypeRegistry.php
@@ -0,0 +1,28 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Gadget;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\WidgetReview;
+
+class TypeRegistry {
+ /**
+ * Return all concrete types that implement interfaces.
+ *
+ * Pass this to the Schema 'types' config so that inline fragments
+ * (e.g. `... on VariableProduct`) are resolvable.
+ *
+ * @return array
+ */
+ public static function get_interface_implementors(): array {
+ return array(
+ Gadget::get(),
+ Widget::get(),
+ WidgetReview::get(),
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt
new file mode 100644
index 00000000000..5fa7d06253b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt
@@ -0,0 +1 @@
+2026-04-28T11:35:12+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php
new file mode 100644
index 00000000000..1e5242907c1
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php
@@ -0,0 +1,225 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Internal\Api\GraphQLController;
+use Automattic\WooCommerce\Internal\Api\QueryCache;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container as DummyContainer;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLController as DummyGraphQLController;
+use WC_REST_Unit_Test_Case;
+
+/**
+ * Tests for {@see GraphQLController}'s debug-mode surface: the
+ * complexity / depth metrics surfaced under `extensions.debug`, the
+ * previous-exception chain reporting, and the SerializationError →
+ * BAD_USER_INPUT promotion path.
+ */
+class GraphQLControllerDebugModeTest extends WC_REST_Unit_Test_Case {
+ /**
+ * The system under test.
+ *
+ * @var GraphQLController
+ */
+ private GraphQLController $sut;
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ wp_cache_flush();
+ DummyStore::reset();
+ DummyContainer::reset();
+
+ // Debug mode requires the caller to be an administrator (or a local
+ // environment); make every test run as one.
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $this->sut = new DummyGraphQLController();
+ $this->sut->init( new QueryCache() );
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ DummyStore::reset();
+ DummyContainer::reset();
+ wp_set_current_user( 0 );
+ wp_cache_flush();
+ parent::tearDown();
+ }
+
+ /**
+ * Build a POST request to /wc/graphql with the given body and an
+ * optional `_debug=1` query-string trigger.
+ *
+ * @param array $body Request body params (query, variables, operationName, ...).
+ * @param bool $debug When true, set `_debug=1` so handle_request enters debug mode.
+ */
+ private function post_request( array $body, bool $debug = false ): \WP_REST_Request {
+ $request = new \WP_REST_Request( 'POST', '/wc/graphql' );
+ foreach ( $body as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+ if ( $debug ) {
+ $request->set_query_params( array( '_debug' => '1' ) );
+ }
+ return $request;
+ }
+
+ /**
+ * @testdox debug mode adds extensions.debug.complexity and extensions.debug.depth to successful responses.
+ */
+ public function test_debug_mode_emits_complexity_and_depth_metrics(): void {
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ greeting { result } }' ), true )
+ );
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'extensions', $data );
+ $this->assertArrayHasKey( 'debug', $data['extensions'] );
+ $this->assertArrayHasKey( 'complexity', $data['extensions']['debug'] );
+ $this->assertArrayHasKey( 'depth', $data['extensions']['debug'] );
+ $this->assertIsInt( $data['extensions']['debug']['complexity'] );
+
+ // Depth is reported as { tree_only, in_depth }: tree_only counts
+ // only intermediate (non-leaf) levels, in_depth counts every field
+ // in the deepest chain. `greeting { result }` has no nested
+ // selection set under `greeting` and one leaf at depth 2.
+ $depth = $data['extensions']['debug']['depth'];
+ $this->assertIsArray( $depth );
+ $this->assertArrayHasKey( 'tree_only', $depth );
+ $this->assertArrayHasKey( 'in_depth', $depth );
+ $this->assertSame( 0, $depth['tree_only'] );
+ $this->assertSame( 2, $depth['in_depth'] );
+ }
+
+ /**
+ * @testdox debug mode is off without _debug=1 and WP_DEBUG, even for an admin.
+ */
+ public function test_debug_mode_is_off_without_trigger(): void {
+ // Define WP_DEBUG only if it isn't already; we can't undefine constants.
+ if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
+ $this->markTestSkipped( 'Cannot exercise the "no debug trigger" branch when WP_DEBUG is on.' );
+ }
+
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ greeting { result } }' ), false )
+ );
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ // No debug metrics should be attached when neither _debug=1 nor WP_DEBUG is set.
+ $this->assertArrayNotHasKey( 'debug', $data['extensions'] ?? array() );
+ }
+
+ /**
+ * @testdox debug mode honours operationName when computing the depth metric.
+ */
+ public function test_debug_mode_depth_metric_honours_operation_name(): void {
+ $doc = '
+ query Shallow { greeting { result } }
+ query Deep { widget(id: 1) { id reviews { nodes { id body score } } } }
+ ';
+
+ $shallow = $this->sut->handle_request(
+ $this->post_request(
+ array(
+ 'query' => $doc,
+ 'operationName' => 'Shallow',
+ ),
+ true
+ )
+ );
+ $deep = $this->sut->handle_request(
+ $this->post_request(
+ array(
+ 'query' => $doc,
+ 'operationName' => 'Deep',
+ ),
+ true
+ )
+ );
+
+ // `in_depth` is what surfaces the deepest leaf chain — what users
+ // actually care about when tuning the depth limit. `Shallow` has
+ // just `greeting -> result` (depth 2); `Deep` has
+ // `widget -> reviews -> nodes -> id` (depth 4).
+ $this->assertSame( 2, $shallow->get_data()['extensions']['debug']['depth']['in_depth'] ?? null );
+ $this->assertSame( 4, $deep->get_data()['extensions']['debug']['depth']['in_depth'] ?? null );
+ }
+
+ /**
+ * @testdox debug mode surfaces the previous-exception chain for wrapped INTERNAL_ERRORs.
+ */
+ public function test_debug_mode_emits_previous_chain_for_wrapped_errors(): void {
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ failing(kind: "runtime") { result } }' ), true )
+ );
+
+ $data = $response->get_data();
+ $this->assertSame( 'INTERNAL_ERROR', $data['errors'][0]['extensions']['code'] ?? null );
+
+ $previous = $data['errors'][0]['extensions']['previous'] ?? null;
+ $this->assertIsArray( $previous );
+ $this->assertNotEmpty( $previous );
+ // The wrapped RuntimeException's class and message must be visible
+ // in the chain so a developer can see the real cause.
+ $classes = array_column( $previous, 'class' );
+ $messages = array_column( $previous, 'message' );
+ $this->assertContains( \RuntimeException::class, $classes );
+ $this->assertContains( 'Something blew up.', $messages );
+ }
+
+ /**
+ * @testdox SerializationError on a returned value is promoted to BAD_USER_INPUT (HTTP 400).
+ */
+ public function test_serialization_error_is_promoted_to_bad_user_input(): void {
+ // Increment returns the Int32-out-of-range result 2147483648 (== MAX_INT + 1),
+ // which webonyx's IntType cannot serialize and surfaces as a
+ // SerializationError. The controller's error formatter promotes it.
+ $response = $this->sut->handle_request(
+ $this->post_request(
+ array( 'query' => 'mutation { increment(value: 2147483647, by: 1) { result } }' )
+ )
+ );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'BAD_USER_INPUT', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox debug mode stays off for an authenticated low-privilege user even with `_debug=1`.
+ *
+ * Skipped when the test environment is local (`wp_get_environment_type()`
+ * returns `'local'` or the site URL host is `localhost` / `127.0.0.1`),
+ * because in that case `is_local_environment()` short-circuits the
+ * admin/_debug gate and the test can't tell the two paths apart.
+ */
+ public function test_debug_mode_is_off_for_low_privilege_user_with_debug_param(): void {
+ $reflection = new \ReflectionClass( $this->sut );
+ $is_local = $reflection->getMethod( 'is_local_environment' );
+ $is_local->setAccessible( true );
+ if ( $is_local->invoke( $this->sut ) ) {
+ $this->markTestSkipped( 'is_local_environment() returns true in this environment; the non-admin debug gate is not exercisable.' );
+ }
+
+ $editor = self::factory()->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $editor );
+
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ greeting { result } }' ), true )
+ );
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertArrayNotHasKey( 'debug', $data['extensions'] ?? array() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
new file mode 100644
index 00000000000..8f44a3c3465
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
@@ -0,0 +1,418 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Internal\Api\GraphQLController;
+use Automattic\WooCommerce\Internal\Api\QueryCache;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container as DummyContainer;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLController as DummyGraphQLController;
+use WC_REST_Unit_Test_Case;
+
+/**
+ * End-to-end tests for {@see GraphQLController}, executing real requests
+ * through a controller wired to the dummy fixture API.
+ *
+ * Covers the rules the controller enforces beyond what the schema itself
+ * checks: depth limit, complexity limit, introspection gating, error
+ * formatting, GET / POST routing, mutation rejection over GET, JSON
+ * decoding of variables / extensions, and APQ.
+ */
+class GraphQLControllerExecutionTest extends WC_REST_Unit_Test_Case {
+ /**
+ * The system under test.
+ *
+ * @var GraphQLController
+ */
+ private GraphQLController $sut;
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ wp_cache_flush();
+ DummyStore::reset();
+ DummyContainer::reset();
+ wp_set_current_user( 0 );
+
+ $this->sut = new DummyGraphQLController();
+ $this->sut->init( new QueryCache() );
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ DummyStore::reset();
+ DummyContainer::reset();
+ wp_set_current_user( 0 );
+ wp_cache_flush();
+ parent::tearDown();
+ }
+
+ /**
+ * Build a POST WP_REST_Request with JSON-style body params.
+ *
+ * @param array $body The decoded request body.
+ */
+ private function post_request( array $body ): \WP_REST_Request {
+ $request = new \WP_REST_Request( 'POST', '/wc/graphql' );
+ foreach ( $body as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+ return $request;
+ }
+
+ /**
+ * Build a GET WP_REST_Request with query-string params.
+ *
+ * @param array $params The query-string parameters.
+ */
+ private function get_request( array $params ): \WP_REST_Request {
+ $request = new \WP_REST_Request( 'GET', '/wc/graphql' );
+ foreach ( $params as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+ return $request;
+ }
+
+ /**
+ * @testdox handle_request returns 200 + data for a successful query.
+ */
+ public function test_handle_request_returns_200_for_successful_query(): void {
+ $response = $this->sut->handle_request( $this->post_request( array( 'query' => '{ greeting { result } }' ) ) );
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( 'Hello, world!', $data['data']['greeting']['result'] ?? null );
+ }
+
+ /**
+ * @testdox handle_request decodes JSON-encoded variables on GET requests.
+ */
+ public function test_handle_request_decodes_get_variables_json(): void {
+ $response = $this->sut->handle_request(
+ $this->get_request(
+ array(
+ 'query' => 'query Q($who: String) { greeting(name: $who) { result } }',
+ 'variables' => '{"who":"Bob"}',
+ )
+ )
+ );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'Hello, Bob!', $response->get_data()['data']['greeting']['result'] ?? null );
+ }
+
+ /**
+ * @testdox handle_request rejects malformed variables JSON with INVALID_ARGUMENT.
+ */
+ public function test_handle_request_rejects_malformed_variables_json(): void {
+ $response = $this->sut->handle_request(
+ $this->get_request(
+ array(
+ 'query' => '{ greeting { result } }',
+ 'variables' => 'not-json',
+ )
+ )
+ );
+
+ $this->assertSame( 400, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( 'INVALID_ARGUMENT', $data['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox handle_request rejects non-object variables with INVALID_ARGUMENT.
+ */
+ public function test_handle_request_rejects_scalar_variables(): void {
+ $response = $this->sut->handle_request(
+ $this->get_request(
+ array(
+ 'query' => '{ greeting { result } }',
+ 'variables' => '"a string"',
+ )
+ )
+ );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'INVALID_ARGUMENT', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox handle_request rejects mutations over GET with METHOD_NOT_ALLOWED.
+ */
+ public function test_handle_request_rejects_mutation_over_get(): void {
+ $response = $this->sut->handle_request(
+ $this->get_request( array( 'query' => 'mutation { increment(value: 1) { result } }' ) )
+ );
+
+ $this->assertSame( 405, $response->get_status() );
+ $this->assertSame( 'METHOD_NOT_ALLOWED', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox handle_request maps UNAUTHORIZED errors to HTTP 401.
+ */
+ public function test_handle_request_maps_unauthorized_to_401(): void {
+ // `widget` requires manage_options.
+ $response = $this->sut->handle_request( $this->post_request( array( 'query' => '{ widget(id: 1) { id } }' ) ) );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( 'UNAUTHORIZED', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox handle_request rejects queries that exceed the configured depth limit.
+ */
+ public function test_handle_request_rejects_excessive_depth(): void {
+ // DEFAULT_MAX_QUERY_DEPTH is 15. Build a deeply nested query well past that.
+ $inner = 'id';
+ for ( $i = 0; $i < 20; $i++ ) {
+ $inner = "reviews { nodes { $inner } }";
+ }
+ $query = "{ widget(id: 1) { $inner } }";
+
+ $response = $this->sut->handle_request( $this->post_request( array( 'query' => $query ) ) );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertNotEmpty( $response->get_data()['errors'] ?? array() );
+ }
+
+ /**
+ * @testdox handle_request rejects queries that exceed the configured complexity limit.
+ */
+ public function test_handle_request_rejects_excessive_complexity(): void {
+ // Complexity for the widgets connection field with first=100 and a
+ // child of complexity ≥ 11 will exceed DEFAULT_MAX_QUERY_COMPLEXITY (1000).
+ $query = '{
+ widgets(first: 100) {
+ nodes {
+ reviews(first: 100) {
+ nodes { id body score }
+ }
+ }
+ }
+ }';
+
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $response = $this->sut->handle_request( $this->post_request( array( 'query' => $query ) ) );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertNotEmpty( $response->get_data()['errors'] ?? array() );
+ }
+
+ /**
+ * @testdox handle_request blocks introspection for low-privilege callers.
+ */
+ public function test_handle_request_blocks_introspection_for_unauthorized_users(): void {
+ wp_set_current_user( 0 );
+
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ __schema { queryType { name } } }' ) )
+ );
+
+ $data = $response->get_data();
+ // Either errors out or returns no data — depending on validation rule.
+ $this->assertNotEmpty( $data['errors'] ?? array() );
+ }
+
+ /**
+ * @testdox handle_request allows introspection for users with manage_woocommerce.
+ */
+ public function test_handle_request_allows_introspection_for_admins(): void {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ __schema { queryType { name } } }' ) )
+ );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'Query', $response->get_data()['data']['__schema']['queryType']['name'] ?? null );
+ }
+
+ /**
+ * @testdox handle_request returns a syntax error as HTTP 400 GRAPHQL_PARSE_ERROR.
+ */
+ public function test_handle_request_handles_syntax_error(): void {
+ $response = $this->sut->handle_request( $this->post_request( array( 'query' => '{ widget(id:' ) ) );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'GRAPHQL_PARSE_ERROR', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox handle_request returns BAD_REQUEST when the request has no query and no APQ.
+ */
+ public function test_handle_request_rejects_missing_query(): void {
+ $response = $this->sut->handle_request( $this->post_request( array() ) );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'BAD_REQUEST', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox handle_request executes a previously-registered APQ via hash-only follow-up.
+ */
+ public function test_handle_request_apq_round_trip(): void {
+ $query = '{ greeting { result } }';
+ $hash = hash( 'sha256', $query );
+
+ $register = $this->sut->handle_request(
+ $this->post_request(
+ array(
+ 'query' => $query,
+ 'extensions' => array(
+ 'persistedQuery' => array(
+ 'version' => 1,
+ 'sha256Hash' => $hash,
+ ),
+ ),
+ )
+ )
+ );
+ $this->assertSame( 200, $register->get_status() );
+
+ $followup = $this->sut->handle_request(
+ $this->post_request(
+ array(
+ 'extensions' => array(
+ 'persistedQuery' => array(
+ 'version' => 1,
+ 'sha256Hash' => $hash,
+ ),
+ ),
+ )
+ )
+ );
+
+ $this->assertSame( 200, $followup->get_status() );
+ $this->assertSame( 'Hello, world!', $followup->get_data()['data']['greeting']['result'] ?? null );
+ }
+
+ /**
+ * @testdox handle_request returns PERSISTED_QUERY_NOT_FOUND on HTTP 200 for unknown hashes.
+ */
+ public function test_handle_request_apq_unknown_hash_returns_200(): void {
+ $response = $this->sut->handle_request(
+ $this->post_request(
+ array(
+ 'extensions' => array(
+ 'persistedQuery' => array(
+ 'version' => 1,
+ 'sha256Hash' => str_repeat( '0', 64 ),
+ ),
+ ),
+ )
+ )
+ );
+
+ // APQ hash misses are intentionally HTTP 200 per the Apollo protocol.
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'PERSISTED_QUERY_NOT_FOUND', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox handle_request falls back to HTTP 500 for resolver errors with no entry in the controller's status map.
+ */
+ public function test_handle_request_translates_failing_query_errors(): void {
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ failing(kind: "api_exception") { result } }' ) )
+ );
+
+ // CUSTOM_FAILURE has no entry in the controller's status map, so it
+ // falls back to 500.
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'CUSTOM_FAILURE', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox handle_request honours operationName when deciding whether to reject a mutation over GET.
+ */
+ public function test_handle_request_uses_operation_name_for_mutation_check(): void {
+ $multi_op = 'query GetIt { greeting { result } } mutation DoIt { increment(value: 1) { result } }';
+
+ // Picking the query operation must NOT trip the mutation-over-GET guard.
+ $query_response = $this->sut->handle_request(
+ $this->get_request(
+ array(
+ 'query' => $multi_op,
+ 'operationName' => 'GetIt',
+ )
+ )
+ );
+ $this->assertSame( 200, $query_response->get_status() );
+ $this->assertSame( 'Hello, world!', $query_response->get_data()['data']['greeting']['result'] ?? null );
+
+ // Picking the mutation operation in the same document MUST be rejected.
+ $mutation_response = $this->sut->handle_request(
+ $this->get_request(
+ array(
+ 'query' => $multi_op,
+ 'operationName' => 'DoIt',
+ )
+ )
+ );
+ $this->assertSame( 405, $mutation_response->get_status() );
+ $this->assertSame(
+ 'METHOD_NOT_ALLOWED',
+ $mutation_response->get_data()['errors'][0]['extensions']['code'] ?? null
+ );
+ }
+
+ /**
+ * @testdox a wrong-type field argument is rejected as BAD_USER_INPUT (HTTP 400).
+ */
+ public function test_handle_request_field_arg_type_mismatch_is_bad_user_input(): void {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ widget(id: "not-an-int") { id } }' ) )
+ );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'BAD_USER_INPUT', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox a missing required field argument is rejected as BAD_USER_INPUT (HTTP 400).
+ */
+ public function test_handle_request_missing_required_arg_is_bad_user_input(): void {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ widget { id } }' ) )
+ );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'BAD_USER_INPUT', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox a variable whose value doesn't match its declared type is rejected as BAD_USER_INPUT.
+ */
+ public function test_handle_request_variable_type_mismatch_is_bad_user_input(): void {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $response = $this->sut->handle_request(
+ $this->post_request(
+ array(
+ 'query' => 'query Q($id: Int!) { widget(id: $id) { id } }',
+ 'variables' => array( 'id' => 'not-an-int' ),
+ )
+ )
+ );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'BAD_USER_INPUT', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLEndpointRegistrarTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLEndpointRegistrarTest.php
new file mode 100644
index 00000000000..276d5cd4a2a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLEndpointRegistrarTest.php
@@ -0,0 +1,128 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLController as AutogeneratedGraphQLController;
+use Automattic\WooCommerce\Internal\Api\GraphQLEndpointRegistrar;
+use Automattic\WooCommerce\Internal\Api\Main;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use WC_REST_Unit_Test_Case;
+
+/**
+ * Tests for {@see GraphQLEndpointRegistrar} — the deferred-registration helper
+ * used by sibling plugins via {@see Main::register_graphql_endpoint()}.
+ */
+class GraphQLEndpointRegistrarTest extends WC_REST_Unit_Test_Case {
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->enable_or_disable_feature( true );
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ $this->enable_or_disable_feature( false );
+ delete_option( Main::OPTION_GET_ENDPOINT_ENABLED );
+ parent::tearDown();
+ }
+
+ /**
+ * Toggle the dual_code_graphql_api feature flag via its underlying option.
+ *
+ * @param bool $enable True to enable, false to disable.
+ */
+ private function enable_or_disable_feature( bool $enable ): void {
+ update_option(
+ wc_get_container()->get( FeaturesController::class )->feature_enable_option_name( 'dual_code_graphql_api' ),
+ $enable ? 'yes' : 'no'
+ );
+ }
+
+ /**
+ * @testdox handle_rest_api_init registers a route with the configured methods.
+ */
+ public function test_handle_rest_api_init_registers_route_with_methods(): void {
+ update_option( Main::OPTION_GET_ENDPOINT_ENABLED, 'yes' );
+
+ $registrar = new GraphQLEndpointRegistrar(
+ AutogeneratedGraphQLController::class,
+ 'wc-test',
+ '/registrar-route',
+ array( 'GET', 'POST' )
+ );
+
+ $registrar->handle_rest_api_init();
+
+ $routes = rest_get_server()->get_routes();
+ $this->assertArrayHasKey( '/wc-test/registrar-route', $routes );
+ $methods = $routes['/wc-test/registrar-route'][0]['methods'];
+ $this->assertTrue( $methods['GET'] ?? false );
+ $this->assertTrue( $methods['POST'] ?? false );
+ }
+
+ /**
+ * @testdox handle_rest_api_init applies the GET-endpoint setting.
+ */
+ public function test_handle_rest_api_init_strips_get_when_setting_is_off(): void {
+ update_option( Main::OPTION_GET_ENDPOINT_ENABLED, 'no' );
+
+ $registrar = new GraphQLEndpointRegistrar(
+ AutogeneratedGraphQLController::class,
+ 'wc-test',
+ '/post-only-route',
+ array( 'GET', 'POST' )
+ );
+
+ $registrar->handle_rest_api_init();
+
+ $routes = rest_get_server()->get_routes();
+ $this->assertArrayHasKey( '/wc-test/post-only-route', $routes );
+ $methods = $routes['/wc-test/post-only-route'][0]['methods'];
+ $this->assertTrue( $methods['POST'] ?? false );
+ $this->assertFalse( $methods['GET'] ?? false );
+ }
+
+ /**
+ * @testdox handle_rest_api_init skips registration when settings reduce the methods to none.
+ */
+ public function test_handle_rest_api_init_skips_registration_when_no_methods_remain(): void {
+ update_option( Main::OPTION_GET_ENDPOINT_ENABLED, 'no' );
+
+ $registrar = new GraphQLEndpointRegistrar(
+ AutogeneratedGraphQLController::class,
+ 'wc-test',
+ '/get-only-route',
+ array( 'GET' )
+ );
+
+ $registrar->handle_rest_api_init();
+
+ $routes = rest_get_server()->get_routes();
+ $this->assertArrayNotHasKey( '/wc-test/get-only-route', $routes );
+ }
+
+ /**
+ * @testdox handle_rest_api_init skips registration when the feature is disabled.
+ */
+ public function test_handle_rest_api_init_skips_registration_when_feature_is_off(): void {
+ $this->enable_or_disable_feature( false );
+
+ $registrar = new GraphQLEndpointRegistrar(
+ AutogeneratedGraphQLController::class,
+ 'wc-test',
+ '/disabled-feature-route',
+ array( 'POST' )
+ );
+
+ $registrar->handle_rest_api_init();
+
+ $routes = rest_get_server()->get_routes();
+ $this->assertArrayNotHasKey( '/wc-test/disabled-feature-route', $routes );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/MainTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/MainTest.php
new file mode 100644
index 00000000000..395fd7e50d5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/MainTest.php
@@ -0,0 +1,384 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLController as AutogeneratedGraphQLController;
+use Automattic\WooCommerce\Internal\Api\GraphQLController;
+use Automattic\WooCommerce\Internal\Api\Main;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for {@see Main} — the entry point that gates registration on PHP/feature
+ * flag, exposes the GET-endpoint setting, and validates plugin-supplied
+ * controller arguments.
+ */
+class MainTest extends WC_Unit_Test_Case {
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->enable_or_disable_feature( true );
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ $this->enable_or_disable_feature( false );
+ delete_option( Main::OPTION_GET_ENDPOINT_ENABLED );
+ parent::tearDown();
+ }
+
+ /**
+ * Toggle the dual_code_graphql_api feature flag via its underlying option.
+ *
+ * @param bool $enable True to enable, false to disable.
+ */
+ private function enable_or_disable_feature( bool $enable ): void {
+ update_option(
+ wc_get_container()->get( FeaturesController::class )->feature_enable_option_name( 'dual_code_graphql_api' ),
+ $enable ? 'yes' : 'no'
+ );
+ }
+
+ /**
+ * @testdox is_get_endpoint_enabled defaults to true when the option is absent.
+ */
+ public function test_is_get_endpoint_enabled_defaults_to_true(): void {
+ delete_option( Main::OPTION_GET_ENDPOINT_ENABLED );
+ $this->assertTrue( Main::is_get_endpoint_enabled() );
+ }
+
+ /**
+ * @testdox is_get_endpoint_enabled honours the stored option value.
+ */
+ public function test_is_get_endpoint_enabled_reads_the_option(): void {
+ update_option( Main::OPTION_GET_ENDPOINT_ENABLED, 'no' );
+ $this->assertFalse( Main::is_get_endpoint_enabled() );
+
+ update_option( Main::OPTION_GET_ENDPOINT_ENABLED, 'yes' );
+ $this->assertTrue( Main::is_get_endpoint_enabled() );
+ }
+
+ /**
+ * @testdox filter_methods_against_settings strips GET when the option is disabled.
+ */
+ public function test_filter_methods_strips_get_when_disabled(): void {
+ update_option( Main::OPTION_GET_ENDPOINT_ENABLED, 'no' );
+ $filtered = Main::filter_methods_against_settings( array( 'GET', 'POST' ) );
+ $this->assertSame( array( 'POST' ), $filtered );
+ }
+
+ /**
+ * @testdox filter_methods_against_settings keeps GET when the option is enabled.
+ */
+ public function test_filter_methods_keeps_get_when_enabled(): void {
+ update_option( Main::OPTION_GET_ENDPOINT_ENABLED, 'yes' );
+ $filtered = Main::filter_methods_against_settings( array( 'GET', 'POST' ) );
+ $this->assertSame( array( 'GET', 'POST' ), $filtered );
+ }
+
+ /**
+ * @testdox filter_methods_against_settings can leave the list empty when GET is the only entry and the option is off.
+ */
+ public function test_filter_methods_can_return_empty_array(): void {
+ update_option( Main::OPTION_GET_ENDPOINT_ENABLED, 'no' );
+ $this->assertSame( array(), Main::filter_methods_against_settings( array( 'GET' ) ) );
+ }
+
+ /**
+ * @testdox is_enabled returns true when the feature flag is on.
+ */
+ public function test_is_enabled_when_feature_on(): void {
+ $this->enable_or_disable_feature( true );
+ $this->assertTrue( Main::is_enabled() );
+ }
+
+ /**
+ * @testdox is_enabled returns false when the feature flag is off.
+ */
+ public function test_is_enabled_when_feature_off(): void {
+ $this->enable_or_disable_feature( false );
+ $this->assertFalse( Main::is_enabled() );
+ }
+
+ /**
+ * @testdox instantiate_graphql_controller returns null when the feature is disabled.
+ */
+ public function test_instantiate_returns_null_when_disabled(): void {
+ $this->enable_or_disable_feature( false );
+ $this->assertNull( Main::instantiate_graphql_controller( AutogeneratedGraphQLController::class ) );
+ }
+
+ /**
+ * @testdox instantiate_graphql_controller throws when the class is not a controller subclass.
+ */
+ public function test_instantiate_rejects_unrelated_classes(): void {
+ $this->expectException( \InvalidArgumentException::class );
+ Main::instantiate_graphql_controller( \stdClass::class );
+ }
+
+ /**
+ * @testdox instantiate_graphql_controller throws when the class does not exist.
+ */
+ public function test_instantiate_rejects_missing_classes(): void {
+ $this->expectException( \InvalidArgumentException::class );
+ Main::instantiate_graphql_controller( 'Definitely\\Not\\A\\Class' );
+ }
+
+ /**
+ * @testdox instantiate_graphql_controller returns a wired-up controller subclass when enabled.
+ */
+ public function test_instantiate_returns_controller_when_enabled(): void {
+ $controller = Main::instantiate_graphql_controller( AutogeneratedGraphQLController::class );
+
+ $this->assertInstanceOf( GraphQLController::class, $controller );
+ }
+
+ /**
+ * @testdox register_graphql_endpoint is a silent no-op when the feature is disabled.
+ */
+ public function test_register_graphql_endpoint_is_a_no_op_when_disabled(): void {
+ $this->enable_or_disable_feature( false );
+
+ $routes_before = rest_get_server()->get_routes();
+
+ // Must not throw and must leave the REST route map unchanged.
+ Main::register_graphql_endpoint( AutogeneratedGraphQLController::class, 'wc-test', '/no-op' );
+
+ $this->assertSame( $routes_before, rest_get_server()->get_routes() );
+ }
+
+ /**
+ * @testdox register_graphql_endpoint rejects classes that are not GraphQLController subclasses.
+ */
+ public function test_register_graphql_endpoint_rejects_unrelated_classes(): void {
+ $this->expectException( \InvalidArgumentException::class );
+ Main::register_graphql_endpoint( \stdClass::class, 'wc-test', '/bogus' );
+ }
+
+ /**
+ * @testdox register_graphql_endpoint rejects directories that don't contain a generated controller.
+ */
+ public function test_register_graphql_endpoint_rejects_directory_without_controller(): void {
+ $tmp = sys_get_temp_dir() . '/wc-graphql-no-controller-' . uniqid();
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
+ mkdir( $tmp, 0755, true );
+
+ try {
+ $this->expectException( \InvalidArgumentException::class );
+ Main::register_graphql_endpoint( $tmp, 'wc-test', '/missing' );
+ } finally {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+ rmdir( $tmp );
+ }
+ }
+
+ /**
+ * @testdox register hooks handle_rest_api_init_for_core onto rest_api_init.
+ */
+ public function test_register_hooks_rest_api_init(): void {
+ remove_action( 'rest_api_init', array( Main::class, 'handle_rest_api_init_for_core' ) );
+
+ Main::register();
+
+ $this->assertNotFalse(
+ has_action( 'rest_api_init', array( Main::class, 'handle_rest_api_init_for_core' ) ),
+ 'register() should hook handle_rest_api_init_for_core onto rest_api_init.'
+ );
+
+ // Clean up so the hook doesn't leak into other tests.
+ remove_action( 'rest_api_init', array( Main::class, 'handle_rest_api_init_for_core' ) );
+ }
+
+ /**
+ * @testdox register also bootstraps Settings — its filter hooks become active.
+ */
+ public function test_register_bootstraps_settings(): void {
+ // Snapshot the existing hook state so unrelated callbacks attached
+ // elsewhere in the test process aren't dropped by remove_all_filters().
+ $saved_sections = $GLOBALS['wp_filter']['woocommerce_get_sections_advanced'] ?? null;
+ $saved_settings = $GLOBALS['wp_filter']['woocommerce_get_settings_advanced'] ?? null;
+
+ remove_all_filters( 'woocommerce_get_sections_advanced' );
+ remove_all_filters( 'woocommerce_get_settings_advanced' );
+
+ try {
+ Main::register();
+
+ $this->assertNotFalse( has_filter( 'woocommerce_get_sections_advanced' ) );
+ $this->assertNotFalse( has_filter( 'woocommerce_get_settings_advanced' ) );
+ } finally {
+ // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited -- restoring the snapshot taken above.
+ if ( null === $saved_sections ) {
+ unset( $GLOBALS['wp_filter']['woocommerce_get_sections_advanced'] );
+ } else {
+ $GLOBALS['wp_filter']['woocommerce_get_sections_advanced'] = $saved_sections;
+ }
+ if ( null === $saved_settings ) {
+ unset( $GLOBALS['wp_filter']['woocommerce_get_settings_advanced'] );
+ } else {
+ $GLOBALS['wp_filter']['woocommerce_get_settings_advanced'] = $saved_settings;
+ }
+ // phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited
+ remove_action( 'rest_api_init', array( Main::class, 'handle_rest_api_init_for_core' ) );
+ }
+ }
+
+ /**
+ * @testdox handle_rest_api_init_for_core registers the /wc/graphql route when the feature is enabled.
+ */
+ public function test_handle_rest_api_init_for_core_registers_route_when_enabled(): void {
+ // Calling handle_rest_api_init_for_core() directly invokes
+ // register_rest_route() outside of the rest_api_init action, which
+ // triggers a _doing_it_wrong notice. Acknowledge it so the test base
+ // class does not flag it as unexpected.
+ $this->setExpectedIncorrectUsage( 'register_rest_route' );
+
+ // Snapshot the original endpoints so we can restore them after the test,
+ // regardless of what the SUT does to the shared WP_REST_Server.
+ $server = rest_get_server();
+ $reflection = new \ReflectionClass( $server );
+ $prop = $reflection->getProperty( 'endpoints' );
+ $prop->setAccessible( true );
+ $original_endpoints = $prop->getValue( $server );
+
+ try {
+ // Force reset so we can observe registration deterministically.
+ $endpoints = $original_endpoints;
+ unset( $endpoints['/wc/graphql'] );
+ $prop->setValue( $server, $endpoints );
+
+ Main::handle_rest_api_init_for_core();
+
+ $this->assertArrayHasKey( '/wc/graphql', rest_get_server()->get_routes() );
+ } finally {
+ $prop->setValue( $server, $original_endpoints );
+ }
+ }
+
+ /**
+ * @testdox handle_rest_api_init_for_core is a silent no-op when the feature is disabled.
+ */
+ public function test_handle_rest_api_init_for_core_is_noop_when_disabled(): void {
+ $this->enable_or_disable_feature( false );
+
+ // Snapshot the original endpoints so we can restore them after the test.
+ // Without this, removing /wc/graphql here would leak into later tests.
+ $server = rest_get_server();
+ $reflection = new \ReflectionClass( $server );
+ $prop = $reflection->getProperty( 'endpoints' );
+ $prop->setAccessible( true );
+ $original_endpoints = $prop->getValue( $server );
+
+ try {
+ $endpoints = $original_endpoints;
+ unset( $endpoints['/wc/graphql'] );
+ $prop->setValue( $server, $endpoints );
+
+ Main::handle_rest_api_init_for_core();
+
+ $this->assertArrayNotHasKey( '/wc/graphql', rest_get_server()->get_routes() );
+ } finally {
+ $prop->setValue( $server, $original_endpoints );
+ }
+ }
+
+ /**
+ * @testdox resolve_controller_class returns its argument unchanged when given an FQCN.
+ */
+ public function test_resolve_controller_class_passes_through_fqcn(): void {
+ $reflection = new \ReflectionClass( Main::class );
+ $method = $reflection->getMethod( 'resolve_controller_class' );
+ $method->setAccessible( true );
+
+ $result = $method->invoke( null, 'Some\\Plugin\\GraphQLController' );
+
+ $this->assertSame( 'Some\\Plugin\\GraphQLController', $result );
+ }
+
+ /**
+ * @testdox resolve_controller_class extracts the namespace from a generated controller file.
+ */
+ public function test_resolve_controller_class_extracts_namespace_from_directory(): void {
+ $tmp_root = sys_get_temp_dir() . '/wc-graphql-resolve-' . uniqid();
+ $dir = $tmp_root . '/src/Internal/Api/Autogenerated';
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
+ mkdir( $dir, 0755, true );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents,WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents
+ file_put_contents(
+ $dir . '/GraphQLController.php',
+ "<?php\nnamespace Some\\Plugin\\Internal\\Api\\Autogenerated;\nclass GraphQLController {}\n"
+ );
+
+ $reflection = new \ReflectionClass( Main::class );
+ $method = $reflection->getMethod( 'resolve_controller_class' );
+ $method->setAccessible( true );
+
+ try {
+ $result = $method->invoke( null, $tmp_root );
+ $this->assertSame( 'Some\\Plugin\\Internal\\Api\\Autogenerated\\GraphQLController', $result );
+ } finally {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_unlink,WordPress.WP.AlternativeFunctions.unlink_unlink
+ unlink( $dir . '/GraphQLController.php' );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+ rmdir( $dir );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+ rmdir( $tmp_root . '/src/Internal/Api' );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+ rmdir( $tmp_root . '/src/Internal' );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+ rmdir( $tmp_root . '/src' );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+ rmdir( $tmp_root );
+ }
+ }
+
+ /**
+ * @testdox extract_namespace_from_php_source handles common PHP source forms.
+ *
+ * @dataProvider provider_php_source_forms
+ *
+ * @param string $source PHP source fragment.
+ * @param ?string $expected The expected extracted namespace, or null when none.
+ */
+ public function test_extract_namespace_handles_various_source_forms( string $source, ?string $expected ): void {
+ $reflection = new \ReflectionClass( Main::class );
+ $method = $reflection->getMethod( 'extract_namespace_from_php_source' );
+ $method->setAccessible( true );
+
+ $this->assertSame( $expected, $method->invoke( null, $source ) );
+ }
+
+ /**
+ * @return array<string, array{0: string, 1: ?string}>
+ */
+ public function provider_php_source_forms(): array {
+ return array(
+ 'standard multi-line' => array(
+ "<?php\n\nnamespace Foo\\Bar\\Baz;\n\nclass Quux {}\n",
+ 'Foo\\Bar\\Baz',
+ ),
+ 'single-line declaration' => array(
+ '<?php namespace Foo\\Bar; class Quux {}',
+ 'Foo\\Bar',
+ ),
+ 'declare(strict_types) before' => array(
+ "<?php\ndeclare(strict_types=1);\nnamespace Foo\\Bar;\nclass Quux {}\n",
+ 'Foo\\Bar',
+ ),
+ 'no namespace declared' => array(
+ "<?php\nclass Quux {}\n",
+ null,
+ ),
+ 'leading and trailing slashes' => array(
+ "<?php\nnamespace \\Foo\\Bar\\;\nclass Quux {}\n",
+ 'Foo\\Bar',
+ ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/QueryCacheTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/QueryCacheTest.php
new file mode 100644
index 00000000000..d5a4704219d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/QueryCacheTest.php
@@ -0,0 +1,163 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Internal\Api\QueryCache;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for {@see QueryCache} — the AST cache backing both the
+ * standard "parse + cache" path and the Apollo Automatic Persisted Queries
+ * (APQ) protocol used by clients that send a hash instead of the full query.
+ */
+class QueryCacheTest extends WC_Unit_Test_Case {
+ /**
+ * The system under test.
+ *
+ * @var QueryCache
+ */
+ private QueryCache $sut;
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ wp_cache_flush();
+ $this->sut = new QueryCache();
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ wp_cache_flush();
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox resolve parses a plain query and returns a DocumentNode.
+ */
+ public function test_resolve_parses_a_plain_query(): void {
+ $result = $this->sut->resolve( '{ widget { id } }', array() );
+
+ $this->assertInstanceOf( DocumentNode::class, $result );
+ }
+
+ /**
+ * @testdox resolve returns the cached AST on the second call for the same query.
+ */
+ public function test_resolve_returns_cached_document_on_second_call(): void {
+ $first = $this->sut->resolve( '{ widget { id } }', array() );
+ $second = $this->sut->resolve( '{ widget { id } }', array() );
+
+ $this->assertInstanceOf( DocumentNode::class, $first );
+ $this->assertInstanceOf( DocumentNode::class, $second );
+ // Distinct instances are fine; both must represent the same parsed query.
+ $this->assertEquals( $first->toArray(), $second->toArray() );
+ }
+
+ /**
+ * @testdox resolve returns a BAD_REQUEST error when called with a null query and no APQ.
+ */
+ public function test_resolve_rejects_null_query_without_apq(): void {
+ $result = $this->sut->resolve( null, array() );
+
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertSame( 'No query provided.', $result['errors'][0]['message'] ?? null );
+ $this->assertSame( 'BAD_REQUEST', $result['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox resolve surfaces a syntax error as GRAPHQL_PARSE_ERROR.
+ */
+ public function test_resolve_returns_parse_error_for_invalid_syntax(): void {
+ $result = $this->sut->resolve( '{ widget { id', array() );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'GRAPHQL_PARSE_ERROR', $result['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox apq registers a query when both the query and matching hash are provided.
+ */
+ public function test_apq_registers_when_query_and_matching_hash_are_provided(): void {
+ $query = '{ widget { id } }';
+ $hash = hash( 'sha256', $query );
+ $extensions = array(
+ 'persistedQuery' => array(
+ 'version' => 1,
+ 'sha256Hash' => $hash,
+ ),
+ );
+
+ $first = $this->sut->resolve( $query, $extensions );
+ $this->assertInstanceOf( DocumentNode::class, $first );
+
+ // Subsequent hash-only request must hit the cache.
+ $second = $this->sut->resolve( null, $extensions );
+ $this->assertInstanceOf( DocumentNode::class, $second );
+ }
+
+ /**
+ * @testdox apq returns PERSISTED_QUERY_HASH_MISMATCH when the supplied hash doesn't match the query.
+ */
+ public function test_apq_rejects_query_when_hash_does_not_match(): void {
+ $extensions = array(
+ 'persistedQuery' => array(
+ 'version' => 1,
+ 'sha256Hash' => str_repeat( 'a', 64 ),
+ ),
+ );
+
+ $result = $this->sut->resolve( '{ widget { id } }', $extensions );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'PERSISTED_QUERY_HASH_MISMATCH', $result['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox apq returns PERSISTED_QUERY_NOT_FOUND when the hash is unknown.
+ */
+ public function test_apq_returns_not_found_when_hash_is_unknown(): void {
+ $extensions = array(
+ 'persistedQuery' => array(
+ 'version' => 1,
+ 'sha256Hash' => str_repeat( 'b', 64 ),
+ ),
+ );
+
+ $result = $this->sut->resolve( null, $extensions );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'PERSISTED_QUERY_NOT_FOUND', $result['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox apq is ignored when the version is not 1 — falls through to the standard path.
+ */
+ public function test_apq_falls_through_when_version_is_not_one(): void {
+ $extensions = array(
+ 'persistedQuery' => array(
+ 'version' => 2,
+ 'sha256Hash' => str_repeat( 'c', 64 ),
+ ),
+ );
+
+ $result = $this->sut->resolve( '{ widget { id } }', $extensions );
+
+ $this->assertInstanceOf( DocumentNode::class, $result );
+ }
+
+ /**
+ * @testdox get_cache_ttl exposes the configured TTL.
+ */
+ public function test_get_cache_ttl_is_a_day(): void {
+ $this->assertSame( DAY_IN_SECONDS, QueryCache::get_cache_ttl() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/QueryInfoExtractorTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/QueryInfoExtractorTest.php
new file mode 100644
index 00000000000..d226ce135ac
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/QueryInfoExtractorTest.php
@@ -0,0 +1,194 @@
+<?php
+/**
+ * QueryInfoExtractor tests — interact with webonyx AST nodes whose properties
+ * (selectionSet, fieldNodes, variableValues, …) are camelCase by design.
+ *
+ * phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FieldNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FragmentDefinitionNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\OperationDefinitionNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionSetNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\Parser;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\ResolveInfo;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for {@see QueryInfoExtractor}. These exercise the AST → query-info
+ * tree transformation that mappers consume to skip work for unselected fields.
+ */
+class QueryInfoExtractorTest extends WC_Unit_Test_Case {
+ /**
+ * Parse a GraphQL operation and return the top-level FieldNode plus the
+ * fragment-definition map that ResolveInfo would expose.
+ *
+ * @param string $source A GraphQL document containing one query.
+ * @return array{0: FieldNode, 1: array<string, FragmentDefinitionNode>}
+ */
+ private function parse_top_field( string $source ): array {
+ /** @var DocumentNode $doc */
+ $doc = Parser::parse( $source, array( 'noLocation' => true ) );
+ $operation = null;
+ $fragments = array();
+ foreach ( $doc->definitions as $def ) {
+ if ( $def instanceof OperationDefinitionNode ) {
+ $operation = $def;
+ } elseif ( $def instanceof FragmentDefinitionNode ) {
+ $fragments[ $def->name->value ] = $def;
+ }
+ }
+ $this->assertNotNull( $operation );
+ $selections = iterator_to_array( $operation->selectionSet->selections );
+ $top_field = $selections[0];
+ $this->assertInstanceOf( FieldNode::class, $top_field );
+ return array( $top_field, $fragments );
+ }
+
+ /**
+ * @testdox extract returns true for leaf fields with no args or sub-selections.
+ */
+ public function test_extract_marks_leaf_fields_as_true(): void {
+ [ $field ] = $this->parse_top_field( '{ widget(id: 1) { id name } }' );
+
+ $tree = QueryInfoExtractor::extract( $field->selectionSet, array() );
+
+ $this->assertSame( true, $tree['id'] ?? null );
+ $this->assertSame( true, $tree['name'] ?? null );
+ }
+
+ /**
+ * @testdox extract recurses into sub-selections.
+ */
+ public function test_extract_recurses_into_sub_selections(): void {
+ [ $field ] = $this->parse_top_field( '{ widget { reviews { nodes { id body } } } }' );
+
+ $tree = QueryInfoExtractor::extract( $field->selectionSet, array() );
+
+ $this->assertIsArray( $tree['reviews'] );
+ $this->assertIsArray( $tree['reviews']['nodes'] );
+ $this->assertSame( true, $tree['reviews']['nodes']['id'] ?? null );
+ $this->assertSame( true, $tree['reviews']['nodes']['body'] ?? null );
+ }
+
+ /**
+ * @testdox extract captures field arguments under __args.
+ */
+ public function test_extract_captures_field_arguments(): void {
+ [ $field ] = $this->parse_top_field( '{ root { reviews(first: 5, search: "abc") { nodes { id } } } }' );
+
+ $tree = QueryInfoExtractor::extract( $field->selectionSet, array() );
+
+ $this->assertArrayHasKey( '__args', $tree['reviews'] );
+ $this->assertSame( 5, $tree['reviews']['__args']['first'] ?? null );
+ $this->assertSame( 'abc', $tree['reviews']['__args']['search'] ?? null );
+ }
+
+ /**
+ * @testdox extract resolves variable references in arguments.
+ */
+ public function test_extract_resolves_variables(): void {
+ [ $field ] = $this->parse_top_field( 'query Q($n: Int) { root { reviews(first: $n) { nodes { id } } } }' );
+
+ $tree = QueryInfoExtractor::extract( $field->selectionSet, array( 'n' => 42 ) );
+
+ $this->assertSame( 42, $tree['reviews']['__args']['first'] ?? null );
+ }
+
+ /**
+ * @testdox extract represents inline fragments under "...TypeName" keys.
+ */
+ public function test_extract_emits_inline_fragments_with_typename_prefix(): void {
+ [ $field ] = $this->parse_top_field(
+ '{ thing { id ... on Widget { color } ... on Gadget { parts_count } } }'
+ );
+
+ $tree = QueryInfoExtractor::extract( $field->selectionSet, array() );
+
+ $this->assertSame( true, $tree['id'] ?? null );
+ $this->assertArrayHasKey( '...Widget', $tree );
+ $this->assertSame( true, $tree['...Widget']['color'] ?? null );
+ $this->assertArrayHasKey( '...Gadget', $tree );
+ $this->assertSame( true, $tree['...Gadget']['parts_count'] ?? null );
+ }
+
+ /**
+ * @testdox extract expands named fragment spreads inline into the parent.
+ */
+ public function test_extract_inlines_named_fragment_spreads(): void {
+ [ $field, $fragments ] = $this->parse_top_field(
+ 'query Q { thing { ...Core } } fragment Core on Widget { id name }'
+ );
+
+ $tree = QueryInfoExtractor::extract( $field->selectionSet, array(), $fragments );
+
+ $this->assertSame( true, $tree['id'] ?? null );
+ $this->assertSame( true, $tree['name'] ?? null );
+ }
+
+ /**
+ * @testdox extract merges overlapping selections from a fragment spread without dropping detail.
+ */
+ public function test_extract_merges_overlapping_fragment_selections(): void {
+ [ $field, $fragments ] = $this->parse_top_field(
+ 'query Q { thing { reviews { nodes { id } } ...AlsoReviews } } '
+ . 'fragment AlsoReviews on Widget { reviews { nodes { body } } }'
+ );
+
+ $tree = QueryInfoExtractor::extract( $field->selectionSet, array(), $fragments );
+
+ $this->assertIsArray( $tree['reviews']['nodes'] ?? null );
+ $this->assertSame( true, $tree['reviews']['nodes']['id'] ?? null );
+ $this->assertSame( true, $tree['reviews']['nodes']['body'] ?? null );
+ }
+
+ /**
+ * @testdox extract returns an empty array for null selection sets.
+ */
+ public function test_extract_handles_null_selection_set(): void {
+ $this->assertSame( array(), QueryInfoExtractor::extract( null, array() ) );
+ }
+
+ /**
+ * @testdox extract_from_info attaches __args from the top-level args.
+ */
+ public function test_extract_from_info_includes_top_level_args(): void {
+ [ $field ] = $this->parse_top_field( '{ widget(id: 7) { name } }' );
+
+ // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- webonyx ResolveInfo properties.
+ $info = $this->createMock( ResolveInfo::class );
+ $info->fieldNodes = new \ArrayObject( array( $field ) );
+ $info->variableValues = array();
+ $info->fragments = array();
+ // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+
+ $tree = QueryInfoExtractor::extract_from_info( $info, array( 'id' => 7 ) );
+
+ $this->assertSame( 7, $tree['__args']['id'] ?? null );
+ $this->assertSame( true, $tree['name'] ?? null );
+ }
+
+ /**
+ * @testdox extract_from_info skips __args when the args array is empty.
+ */
+ public function test_extract_from_info_omits_args_when_empty(): void {
+ [ $field ] = $this->parse_top_field( '{ widget { name } }' );
+
+ // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- webonyx ResolveInfo properties.
+ $info = $this->createMock( ResolveInfo::class );
+ $info->fieldNodes = new \ArrayObject( array( $field ) );
+ $info->variableValues = array();
+ $info->fragments = array();
+ // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+
+ $tree = QueryInfoExtractor::extract_from_info( $info, array() );
+
+ $this->assertArrayNotHasKey( '__args', $tree );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/README.md b/plugins/woocommerce/tests/php/src/Internal/Api/README.md
new file mode 100644
index 00000000000..a4bda7752fa
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/README.md
@@ -0,0 +1,42 @@
+# GraphQL infrastructure tests
+
+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/`.
+
+## 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`).
+
+Running plain `phpunit` or `pnpm test:php` on any PHP version is therefore safe: it simply doesn't pick these tests up.
+
+## Running the tests locally (PHP 8.1+ only)
+
+Pick one of the following dedicated testsuites:
+
+```sh
+# Just the GraphQL infrastructure tests:
+pnpm test:php:env -- --testsuite=wc-phpunit-graphql-infra
+
+# Or the full set (legacy + main + this directory):
+pnpm test:php:env -- --testsuite=wc-phpunit-full
+```
+
+Without `pnpm`:
+
+```sh
+./vendor/bin/phpunit -c phpunit.xml --testsuite=wc-phpunit-graphql-infra
+./vendor/bin/phpunit -c phpunit.xml --testsuite=wc-phpunit-full
+```
+
+## PHP < 8.1 fallback tests
+
+The few tests that verify the GraphQL feature **gracefully degrades** on PHP versions where it's unavailable (`Settings::add_section()` / `add_settings()` returning the input unchanged) live outside this directory, in `tests/php/src/Internal/LegacyPhpApi/`. They have to live there because they need to be discovered by the default testsuite (which runs on every PHP version) — anything under `Internal/Api/` is excluded from the default testsuite by string-prefix match.
+
+## Regenerating the dummy code-API
+
+Whenever the code-API fixture under `Fixtures/DummyApi/` changes, regenerate the matching tree under `Fixtures/DummyApiAutogenerated/` and commit both:
+
+```sh
+pnpm build:api:test
+```
+
+The script invokes `build-api.php` with the fixture paths and namespaces. It does not run `composer dump-autoload` because both fixture namespaces are already covered by the existing `Automattic\WooCommerce\Tests\` PSR-4 prefix.
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Schema/AliasesTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Schema/AliasesTest.php
new file mode 100644
index 00000000000..edaae56d4ee
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Schema/AliasesTest.php
@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Schema;
+
+use Automattic\WooCommerce\Internal\Api\Schema\AST\StringValueNode as AliasedStringValueNode;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo as AliasedResolveInfo;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\StringValueNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\ResolveInfo;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests that the `aliases.php` bootstrap registers the surface aliases.
+ *
+ * These aliases let generated code reference the engine via the
+ * Internal\Api\Schema namespace even though webonyx itself constructs the
+ * instances. If the alias is broken, every resolver's `resolve()` parameter
+ * type-hint check fails at request time.
+ */
+class AliasesTest extends WC_Unit_Test_Case {
+ /**
+ * @testdox the ResolveInfo alias resolves to the webonyx ResolveInfo class.
+ */
+ public function test_resolve_info_alias_resolves_to_webonyx_resolve_info(): void {
+ $this->assertTrue( class_exists( AliasedResolveInfo::class ) );
+ $this->assertSame(
+ ResolveInfo::class,
+ ( new \ReflectionClass( AliasedResolveInfo::class ) )->getName(),
+ 'The alias must resolve to the webonyx ResolveInfo class so resolver type hints accept what the engine passes.'
+ );
+ }
+
+ /**
+ * @testdox the StringValueNode alias resolves to the webonyx StringValueNode class.
+ */
+ public function test_string_value_node_alias_resolves_to_webonyx_string_value_node(): void {
+ $this->assertTrue( class_exists( AliasedStringValueNode::class ) );
+ $this->assertSame(
+ StringValueNode::class,
+ ( new \ReflectionClass( AliasedStringValueNode::class ) )->getName(),
+ 'The alias must resolve to webonyx StringValueNode so custom-scalar parseLiteral() callbacks see the right type.'
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Schema/SubclassesTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Schema/SubclassesTest.php
new file mode 100644
index 00000000000..8023a432070
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Schema/SubclassesTest.php
@@ -0,0 +1,147 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Schema;
+
+use Automattic\WooCommerce\Internal\Api\Schema\CustomScalarType;
+use Automattic\WooCommerce\Internal\Api\Schema\EnumType;
+use Automattic\WooCommerce\Internal\Api\Schema\Error;
+use Automattic\WooCommerce\Internal\Api\Schema\InputObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\InterfaceType;
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\Schema;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+use Automattic\WooCommerce\Vendor\GraphQL\Error\ClientAware;
+use Automattic\WooCommerce\Vendor\GraphQL\Error\Error as WebonyxError;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\CustomScalarType as WebonyxCustomScalarType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\EnumType as WebonyxEnumType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\InputObjectType as WebonyxInputObjectType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\InterfaceType as WebonyxInterfaceType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\ObjectType as WebonyxObjectType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Schema as WebonyxSchema;
+use WC_Unit_Test_Case;
+
+/**
+ * Smoke tests for the no-op subclasses in the Schema/ surface. Each subclass
+ * must extend its webonyx counterpart and accept the same configuration
+ * payload, since generated code constructs them with webonyx-shaped configs.
+ */
+class SubclassesTest extends WC_Unit_Test_Case {
+ /**
+ * @testdox ObjectType extends the webonyx ObjectType.
+ */
+ public function test_object_type_extends_webonyx_object_type(): void {
+ $type = new ObjectType(
+ array(
+ 'name' => 'Foo',
+ 'fields' => array(
+ 'bar' => array( 'type' => Type::string() ),
+ ),
+ )
+ );
+
+ $this->assertInstanceOf( WebonyxObjectType::class, $type );
+ $this->assertSame( 'Foo', $type->name );
+ }
+
+ /**
+ * @testdox InputObjectType extends the webonyx InputObjectType.
+ */
+ public function test_input_object_type_extends_webonyx_input_object_type(): void {
+ $type = new InputObjectType(
+ array(
+ 'name' => 'FooInput',
+ 'fields' => array(
+ 'bar' => array( 'type' => Type::string() ),
+ ),
+ )
+ );
+
+ $this->assertInstanceOf( WebonyxInputObjectType::class, $type );
+ $this->assertSame( 'FooInput', $type->name );
+ }
+
+ /**
+ * @testdox EnumType extends the webonyx EnumType.
+ */
+ public function test_enum_type_extends_webonyx_enum_type(): void {
+ $type = new EnumType(
+ array(
+ 'name' => 'Status',
+ 'values' => array(
+ 'ACTIVE' => array( 'value' => 'active' ),
+ 'INACTIVE' => array( 'value' => 'inactive' ),
+ ),
+ )
+ );
+
+ $this->assertInstanceOf( WebonyxEnumType::class, $type );
+ $this->assertSame( 'Status', $type->name );
+ }
+
+ /**
+ * @testdox InterfaceType extends the webonyx InterfaceType.
+ */
+ public function test_interface_type_extends_webonyx_interface_type(): void {
+ $type = new InterfaceType(
+ array(
+ 'name' => 'Node',
+ 'fields' => array(
+ 'id' => array( 'type' => Type::nonNull( Type::int() ) ),
+ ),
+ )
+ );
+
+ $this->assertInstanceOf( WebonyxInterfaceType::class, $type );
+ $this->assertSame( 'Node', $type->name );
+ }
+
+ /**
+ * @testdox CustomScalarType extends the webonyx CustomScalarType.
+ */
+ public function test_custom_scalar_type_extends_webonyx_custom_scalar_type(): void {
+ $type = new CustomScalarType(
+ array(
+ 'name' => 'MyDate',
+ 'serialize' => static fn( $v ) => (string) $v,
+ )
+ );
+
+ $this->assertInstanceOf( WebonyxCustomScalarType::class, $type );
+ $this->assertSame( 'MyDate', $type->name );
+ }
+
+ /**
+ * @testdox Schema extends the webonyx Schema and returns its query type.
+ */
+ public function test_schema_extends_webonyx_schema(): void {
+ $query = new ObjectType(
+ array(
+ 'name' => 'Query',
+ 'fields' => array(
+ 'hello' => array(
+ 'type' => Type::string(),
+ 'resolve' => static fn() => 'world',
+ ),
+ ),
+ )
+ );
+
+ $schema = new Schema( array( 'query' => $query ) );
+
+ $this->assertInstanceOf( WebonyxSchema::class, $schema );
+ $this->assertSame( $query, $schema->getQueryType() );
+ }
+
+ /**
+ * @testdox Error extends the webonyx Error and is ClientAware.
+ */
+ public function test_error_extends_webonyx_error_and_is_client_safe(): void {
+ $error = new Error( 'visible to clients' );
+
+ $this->assertInstanceOf( WebonyxError::class, $error );
+ $this->assertInstanceOf( ClientAware::class, $error );
+ $this->assertSame( 'visible to clients', $error->getMessage() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Schema/TypeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Schema/TypeTest.php
new file mode 100644
index 00000000000..629cbcef189
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Schema/TypeTest.php
@@ -0,0 +1,96 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Schema;
+
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\BooleanType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\FloatType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\IDType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\IntType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\ListOfType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\NonNull;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\StringType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\Type as WebonyxType;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the {@see Type} static facade. Each scalar accessor must return
+ * the matching webonyx singleton, and the modifiers must produce wrappers
+ * around the supplied inner type.
+ */
+class TypeTest extends WC_Unit_Test_Case {
+ /**
+ * @testdox int() returns the webonyx Int singleton.
+ */
+ public function test_int_returns_webonyx_int_singleton(): void {
+ $this->assertInstanceOf( IntType::class, Type::int() );
+ $this->assertSame( WebonyxType::int(), Type::int() );
+ }
+
+ /**
+ * @testdox string() returns the webonyx String singleton.
+ */
+ public function test_string_returns_webonyx_string_singleton(): void {
+ $this->assertInstanceOf( StringType::class, Type::string() );
+ $this->assertSame( WebonyxType::string(), Type::string() );
+ }
+
+ /**
+ * @testdox boolean() returns the webonyx Boolean singleton.
+ */
+ public function test_boolean_returns_webonyx_boolean_singleton(): void {
+ $this->assertInstanceOf( BooleanType::class, Type::boolean() );
+ $this->assertSame( WebonyxType::boolean(), Type::boolean() );
+ }
+
+ /**
+ * @testdox float() returns the webonyx Float singleton.
+ */
+ public function test_float_returns_webonyx_float_singleton(): void {
+ $this->assertInstanceOf( FloatType::class, Type::float() );
+ $this->assertSame( WebonyxType::float(), Type::float() );
+ }
+
+ /**
+ * @testdox id() returns the webonyx ID singleton.
+ */
+ public function test_id_returns_webonyx_id_singleton(): void {
+ $this->assertInstanceOf( IDType::class, Type::id() );
+ $this->assertSame( WebonyxType::id(), Type::id() );
+ }
+
+ /**
+ * @testdox nonNull() wraps an inner type.
+ */
+ public function test_non_null_wraps_an_inner_type(): void {
+ $wrapped = Type::nonNull( Type::string() );
+
+ $this->assertInstanceOf( NonNull::class, $wrapped );
+ $this->assertSame( Type::string(), $wrapped->getWrappedType() );
+ }
+
+ /**
+ * @testdox listOf() wraps an inner type.
+ */
+ public function test_list_of_wraps_an_inner_type(): void {
+ $wrapped = Type::listOf( Type::int() );
+
+ $this->assertInstanceOf( ListOfType::class, $wrapped );
+ $this->assertSame( Type::int(), $wrapped->getWrappedType() );
+ }
+
+ /**
+ * @testdox modifiers compose into nested wrappers.
+ */
+ public function test_modifiers_compose(): void {
+ $type = Type::nonNull( Type::listOf( Type::nonNull( Type::int() ) ) );
+
+ $this->assertInstanceOf( NonNull::class, $type );
+ $inner = $type->getWrappedType();
+ $this->assertInstanceOf( ListOfType::class, $inner );
+ $this->assertInstanceOf( NonNull::class, $inner->getWrappedType() );
+ $this->assertSame( Type::int(), $inner->getWrappedType()->getWrappedType() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/SecurityTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/SecurityTest.php
new file mode 100644
index 00000000000..310de04183f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/SecurityTest.php
@@ -0,0 +1,188 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Internal\Api\GraphQLController;
+use Automattic\WooCommerce\Internal\Api\QueryCache;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container as DummyContainer;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLController as DummyGraphQLController;
+use WC_REST_Unit_Test_Case;
+
+/**
+ * Security-focused tests for {@see GraphQLController} and the resolvers it
+ * dispatches to.
+ *
+ * Anonymous and admin paths are covered elsewhere; this file pins down the
+ * authenticated-but-low-privilege scenarios (editor / subscriber), the
+ * aliased-field cap-enforcement contract, the `authorize()`
+ * exception-translation paths, and the route-level authorization marker.
+ */
+class SecurityTest extends WC_REST_Unit_Test_Case {
+ /**
+ * The system under test.
+ *
+ * @var GraphQLController
+ */
+ private GraphQLController $sut;
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ wp_cache_flush();
+ DummyStore::reset();
+ DummyContainer::reset();
+ wp_set_current_user( 0 );
+
+ $this->sut = new DummyGraphQLController();
+ $this->sut->init( new QueryCache() );
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ DummyStore::reset();
+ DummyContainer::reset();
+ wp_set_current_user( 0 );
+ wp_cache_flush();
+ parent::tearDown();
+ }
+
+ /**
+ * Build a POST request to `/wc/graphql` with the given body params.
+ *
+ * @param array $body Request body params (query, variables, …).
+ */
+ private function post_request( array $body ): \WP_REST_Request {
+ $request = new \WP_REST_Request( 'POST', '/wc/graphql' );
+ foreach ( $body as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+ return $request;
+ }
+
+ /**
+ * @testdox an editor user (lacks manage_options) is rejected on a #[RequiredCapability]-gated query.
+ */
+ public function test_editor_user_is_rejected_on_capability_protected_query(): void {
+ $editor = self::factory()->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $editor );
+
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ widget(id: 1) { id } }' ) )
+ );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( 'UNAUTHORIZED', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ /**
+ * @testdox a subscriber user (lacks manage_woocommerce) is blocked from introspection.
+ */
+ public function test_subscriber_user_is_blocked_from_introspection(): void {
+ $subscriber = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ wp_set_current_user( $subscriber );
+
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ __schema { queryType { name } } }' ) )
+ );
+
+ $data = $response->get_data();
+ // The DisableIntrospection rule produces a validation error; the
+ // formatter promotes ClientAware errors with no explicit code to
+ // BAD_USER_INPUT (HTTP 400).
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertNotEmpty( $data['errors'] ?? array() );
+ }
+
+ /**
+ * @testdox aliased calls to a capability-protected field enforce the cap on each alias.
+ */
+ public function test_aliased_capability_protected_fields_enforce_cap_per_alias(): void {
+ $editor = self::factory()->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $editor );
+
+ $response = $this->sut->handle_request(
+ $this->post_request(
+ array( 'query' => '{ a: widget(id: 1) { id } b: widget(id: 2) { id } }' )
+ )
+ );
+
+ $this->assertSame( 401, $response->get_status() );
+
+ $data = $response->get_data();
+ $codes = array_map(
+ static fn( array $err ): ?string => $err['extensions']['code'] ?? null,
+ $data['errors'] ?? array()
+ );
+ $unauthorized_count = count(
+ array_filter( $codes, static fn( ?string $code ): bool => 'UNAUTHORIZED' === $code )
+ );
+ $this->assertGreaterThanOrEqual(
+ 2,
+ $unauthorized_count,
+ 'Both aliased resolutions must enforce the cap independently.'
+ );
+ }
+
+ /**
+ * @testdox an authorize() that throws ApiException carries its custom code through to the wire.
+ */
+ public function test_authorize_throwing_api_exception_carries_code_through(): void {
+ wp_set_current_user( 0 );
+
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ authorizeThrows(kind: "api_exception") { result } }' ) )
+ );
+
+ $data = $response->get_data();
+ $this->assertSame( 'AUTH_FAILURE', $data['errors'][0]['extensions']['code'] ?? null );
+ $this->assertSame( 'Authorize failed.', $data['errors'][0]['message'] ?? null );
+ $this->assertSame( 'extra', $data['errors'][0]['extensions']['detail'] ?? null );
+ }
+
+ /**
+ * @testdox an authorize() that throws an arbitrary exception is masked behind INTERNAL_ERROR.
+ */
+ public function test_authorize_throwing_runtime_exception_masks_message(): void {
+ wp_set_current_user( 0 );
+
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ authorizeThrows(kind: "runtime") { result } }' ) )
+ );
+
+ $data = $response->get_data();
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'INTERNAL_ERROR', $data['errors'][0]['extensions']['code'] ?? null );
+ // The wrapping GraphQLError carries 'An unexpected error occurred.', but
+ // because its `previous` is a non-ClientAware throwable webonyx replaces
+ // the message with its generic 'Internal server error' on the wire. The
+ // security-relevant invariant is that the original exception message
+ // never leaves the resolver.
+ $this->assertStringNotContainsString(
+ 'Internals leaked from authorize.',
+ wp_json_encode( $data['errors'][0] )
+ );
+ }
+
+ /**
+ * @testdox the REST route registers with `__return_true` as its permission_callback.
+ *
+ * Auth is enforced per-query / per-mutation in the generated resolvers, not
+ * at the REST route level. Any future change that adds a route-level
+ * permission gate (e.g., `manage_woocommerce`) would silently break the
+ * public queries — this test pins the design intent.
+ */
+ public function test_rest_route_uses_return_true_permission_callback(): void {
+ $this->sut->register();
+
+ $routes = rest_get_server()->get_routes();
+ $this->assertArrayHasKey( '/wc/graphql', $routes );
+ $this->assertSame( '__return_true', $routes['/wc/graphql'][0]['permission_callback'] );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
index 34a01815c51..5f91d1f98f7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
@@ -22,21 +22,10 @@ class SettingsTest extends WC_Unit_Test_Case {
/**
* Set up before each test.
- *
- * Skips on PHP < 8.1 because the settings fields reference
- * GraphQLController constants, and that class uses PHP 8.0+ syntax that
- * cannot be parsed on 7.4. In production the class is only loaded after
- * {@see Main::is_enabled()} gates on PHP 8.1+; these tests replicate the
- * same gate so the autoload never triggers a parse error.
*/
public function setUp(): void {
parent::setUp();
$this->enable_or_disable_feature( true );
-
- if ( PHP_VERSION_ID < 80100 ) {
- $this->markTestSkipped( 'GraphQL settings tests require PHP 8.1+.' );
- }
-
$this->sut = new Settings();
}
@@ -77,13 +66,9 @@ class SettingsTest extends WC_Unit_Test_Case {
}
/**
- * @testdox add_section appends the graphql section while preserving existing ones (PHP 8.1+).
+ * @testdox add_section appends the graphql section while preserving existing ones.
*/
public function test_add_section_appends_graphql_section(): void {
- if ( PHP_VERSION_ID < 80100 ) {
- $this->markTestSkipped( 'GraphQL settings require PHP 8.1+.' );
- }
-
$result = $this->sut->add_section( array( 'features' => 'Features' ) );
$this->assertArrayHasKey( Settings::SECTION_ID, $result );
@@ -91,27 +76,9 @@ class SettingsTest extends WC_Unit_Test_Case {
}
/**
- * @testdox add_section is a no-op on PHP < 8.1.
- */
- public function test_add_section_is_noop_on_unsupported_php(): void {
- if ( PHP_VERSION_ID >= 80100 ) {
- $this->markTestSkipped( 'Only relevant on PHP < 8.1.' );
- }
-
- $input = array( 'features' => 'Features' );
- $result = $this->sut->add_section( $input );
-
- $this->assertSame( $input, $result );
- }
-
- /**
- * @testdox add_settings defines the GET endpoint checkbox with a 'yes' default (PHP 8.1+).
+ * @testdox add_settings defines the GET endpoint checkbox with a 'yes' default.
*/
public function test_add_settings_defines_get_endpoint_checkbox(): void {
- if ( PHP_VERSION_ID < 80100 ) {
- $this->markTestSkipped( 'GraphQL settings require PHP 8.1+.' );
- }
-
$fields = $this->sut->add_settings( array(), Settings::SECTION_ID );
$by_id = array_column( $fields, null, 'id' );
@@ -120,20 +87,6 @@ class SettingsTest extends WC_Unit_Test_Case {
$this->assertSame( 'yes', $by_id[ Main::OPTION_GET_ENDPOINT_ENABLED ]['default'] );
}
- /**
- * @testdox add_settings returns the input unchanged on PHP < 8.1.
- */
- public function test_add_settings_is_noop_on_unsupported_php(): void {
- if ( PHP_VERSION_ID >= 80100 ) {
- $this->markTestSkipped( 'Only relevant on PHP < 8.1.' );
- }
-
- $input = array( array( 'id' => 'existing' ) );
- $result = $this->sut->add_settings( $input, Settings::SECTION_ID );
-
- $this->assertSame( $input, $result );
- }
-
/**
* @testdox add_settings defines the max query depth field with min=1 and the default constant as default.
*/
@@ -165,4 +118,37 @@ class SettingsTest extends WC_Unit_Test_Case {
);
$this->assertSame( '1', $by_id[ Main::OPTION_MAX_QUERY_COMPLEXITY ]['custom_attributes']['min'] );
}
+
+ /**
+ * @testdox add_settings returns the original settings unchanged when the section id does not match.
+ */
+ public function test_add_settings_passes_through_for_other_sections(): void {
+ $existing = array( array( 'id' => 'placeholder' ) );
+
+ $result = $this->sut->add_settings( $existing, 'some_other_section' );
+
+ $this->assertSame( $existing, $result );
+ }
+
+ /**
+ * @testdox add_section returns sections unchanged when the feature is disabled.
+ */
+ public function test_add_section_does_not_register_when_feature_is_off(): void {
+ $this->enable_or_disable_feature( false );
+
+ $result = $this->sut->add_section( array( 'features' => 'Features' ) );
+
+ $this->assertArrayNotHasKey( Settings::SECTION_ID, $result );
+ }
+
+ /**
+ * @testdox add_settings returns settings unchanged when the feature is disabled.
+ */
+ public function test_add_settings_does_not_register_when_feature_is_off(): void {
+ $this->enable_or_disable_feature( false );
+
+ $result = $this->sut->add_settings( array(), Settings::SECTION_ID );
+
+ $this->assertSame( array(), $result );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/UtilsTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/UtilsTest.php
new file mode 100644
index 00000000000..d87ad610b10
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/UtilsTest.php
@@ -0,0 +1,249 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Pagination\PaginationParams;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Vendor\GraphQL\Error\Error as GraphQLError;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for {@see Utils} — the shared helper layer that autogenerated
+ * resolvers route capability checks, pagination construction, and exception
+ * translation through.
+ */
+class UtilsTest extends WC_Unit_Test_Case {
+ /**
+ * @testdox check_current_user_can throws an UNAUTHORIZED GraphQL error when the cap is missing.
+ */
+ public function test_check_current_user_can_throws_unauthorized_when_cap_is_missing(): void {
+ wp_set_current_user( 0 );
+
+ try {
+ Utils::check_current_user_can( 'manage_options' );
+ $this->fail( 'Expected GraphQLError to be thrown.' );
+ } catch ( GraphQLError $e ) {
+ $this->assertSame( 'You do not have permission to perform this action.', $e->getMessage() );
+ $extensions = $e->getExtensions();
+ $this->assertIsArray( $extensions );
+ $this->assertSame( 'UNAUTHORIZED', $extensions['code'] ?? null );
+ }
+ }
+
+ /**
+ * @testdox check_current_user_can returns silently when the cap is held.
+ */
+ public function test_check_current_user_can_passes_when_cap_is_held(): void {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ // Should not throw.
+ Utils::check_current_user_can( 'manage_options' );
+
+ $this->assertTrue( true );
+ wp_set_current_user( 0 );
+ }
+
+ /**
+ * @testdox complexity_from_pagination scales the cost by the requested page size.
+ */
+ public function test_complexity_from_pagination_uses_first_when_provided(): void {
+ // child_complexity 5, first 10 → 10 * (5 + 1) = 60.
+ $this->assertSame( 60, Utils::complexity_from_pagination( 5, array( 'first' => 10 ) ) );
+ }
+
+ /**
+ * @testdox complexity_from_pagination falls back to MAX_PAGE_SIZE for invalid values.
+ */
+ public function test_complexity_from_pagination_clamps_invalid_values(): void {
+ $max = PaginationParams::MAX_PAGE_SIZE;
+
+ $this->assertSame( $max * ( 1 + 1 ), Utils::complexity_from_pagination( 1, array( 'first' => -5 ) ) );
+ $this->assertSame( $max * ( 1 + 1 ), Utils::complexity_from_pagination( 1, array( 'first' => $max + 1 ) ) );
+ $this->assertSame( $max * ( 1 + 1 ), Utils::complexity_from_pagination( 1, array( 'first' => 'not-an-int' ) ) );
+ }
+
+ /**
+ * @testdox complexity_from_pagination uses the default page size when no limit is supplied.
+ */
+ public function test_complexity_from_pagination_uses_default_when_omitted(): void {
+ $default = PaginationParams::get_default_page_size();
+ $this->assertSame( $default * ( 2 + 1 ), Utils::complexity_from_pagination( 2, array() ) );
+ }
+
+ /**
+ * @testdox create_pagination_params builds a PaginationParams from raw args.
+ */
+ public function test_create_pagination_params_builds_a_pagination_params(): void {
+ $params = Utils::create_pagination_params(
+ array(
+ 'first' => 5,
+ 'after' => 'cursor-a',
+ 'before' => null,
+ )
+ );
+
+ $this->assertInstanceOf( PaginationParams::class, $params );
+ $this->assertSame( 5, $params->first );
+ $this->assertNull( $params->last );
+ $this->assertSame( 'cursor-a', $params->after );
+ }
+
+ /**
+ * @testdox create_pagination_params translates an InvalidArgumentException into INVALID_ARGUMENT.
+ */
+ public function test_create_pagination_params_translates_invalid_arguments(): void {
+ $this->expectException( GraphQLError::class );
+ try {
+ Utils::create_pagination_params( array( 'first' => -1 ) );
+ } catch ( GraphQLError $e ) {
+ $this->assertSame( 'INVALID_ARGUMENT', $e->getExtensions()['code'] ?? null );
+ throw $e;
+ }
+ }
+
+ /**
+ * @testdox execute_command forwards return values from the command.
+ */
+ public function test_execute_command_returns_command_result(): void {
+ $command = new class() {
+ /**
+ * Adds two numbers.
+ *
+ * @param int $a First operand.
+ * @param int $b Second operand.
+ */
+ public function execute( int $a, int $b ): int {
+ return $a + $b;
+ }
+ };
+
+ $result = Utils::execute_command(
+ $command,
+ array(
+ 'a' => 2,
+ 'b' => 5,
+ )
+ );
+ $this->assertSame( 7, $result );
+ }
+
+ /**
+ * @testdox execute_command translates ApiException into a coded GraphQL error.
+ */
+ public function test_execute_command_translates_api_exception(): void {
+ $command = new class() {
+ /**
+ * Always throws.
+ */
+ public function execute(): void {
+ throw new ApiException( 'Coupon not found.', 'NOT_FOUND', array( 'detail' => 'extra' ), 404 );
+ }
+ };
+
+ try {
+ Utils::execute_command( $command, array() );
+ $this->fail( 'Expected GraphQLError to be thrown.' );
+ } catch ( GraphQLError $e ) {
+ $this->assertSame( 'Coupon not found.', $e->getMessage() );
+ $extensions = $e->getExtensions();
+ $this->assertSame( 'NOT_FOUND', $extensions['code'] ?? null );
+ $this->assertSame( 'extra', $extensions['detail'] ?? null );
+ }
+ }
+
+ /**
+ * @testdox execute_command preserves the canonical code over a colliding extensions entry.
+ */
+ public function test_execute_command_canonical_code_wins_over_extensions_entry(): void {
+ $command = new class() {
+ /**
+ * Always throws.
+ */
+ public function execute(): void {
+ throw new ApiException( 'Sneaky', 'NOT_FOUND', array( 'code' => 'OVERRIDDEN' ), 404 );
+ }
+ };
+
+ try {
+ Utils::execute_command( $command, array() );
+ $this->fail( 'Expected GraphQLError to be thrown.' );
+ } catch ( GraphQLError $e ) {
+ $this->assertSame( 'NOT_FOUND', $e->getExtensions()['code'] ?? null );
+ }
+ }
+
+ /**
+ * @testdox execute_command translates InvalidArgumentException to INVALID_ARGUMENT.
+ */
+ public function test_execute_command_translates_invalid_argument(): void {
+ $command = new class() {
+ /**
+ * Always throws.
+ */
+ public function execute(): void {
+ throw new \InvalidArgumentException( 'bad input' );
+ }
+ };
+
+ try {
+ Utils::execute_command( $command, array() );
+ $this->fail( 'Expected GraphQLError to be thrown.' );
+ } catch ( GraphQLError $e ) {
+ $this->assertSame( 'bad input', $e->getMessage() );
+ $this->assertSame( 'INVALID_ARGUMENT', $e->getExtensions()['code'] ?? null );
+ }
+ }
+
+ /**
+ * @testdox execute_command masks unknown throwables behind INTERNAL_ERROR.
+ */
+ public function test_execute_command_masks_other_throwables(): void {
+ $command = new class() {
+ /**
+ * Always throws.
+ */
+ public function execute(): void {
+ throw new \RuntimeException( 'leaky internals' );
+ }
+ };
+
+ try {
+ Utils::execute_command( $command, array() );
+ $this->fail( 'Expected GraphQLError to be thrown.' );
+ } catch ( GraphQLError $e ) {
+ $this->assertSame( 'An unexpected error occurred.', $e->getMessage() );
+ $this->assertSame( 'INTERNAL_ERROR', $e->getExtensions()['code'] ?? null );
+ $this->assertInstanceOf( \RuntimeException::class, $e->getPrevious() );
+ }
+ }
+
+ /**
+ * @testdox authorize_command forwards the boolean result.
+ */
+ public function test_authorize_command_forwards_result(): void {
+ $command = new class() {
+ /**
+ * Echoes the supplied flag.
+ *
+ * @param bool $allow Whether to allow.
+ */
+ public function authorize( bool $allow ): bool {
+ return $allow;
+ }
+ };
+
+ $this->assertTrue( Utils::authorize_command( $command, array( 'allow' => true ) ) );
+ $this->assertFalse( Utils::authorize_command( $command, array( 'allow' => false ) ) );
+ }
+
+ /**
+ * @testdox translate_exceptions returns the callable result on success.
+ */
+ public function test_translate_exceptions_returns_callable_result(): void {
+ $this->assertSame( 'ok', Utils::translate_exceptions( static fn() => 'ok' ) );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/LegacyPhpApi/SettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/LegacyPhpApi/SettingsTest.php
new file mode 100644
index 00000000000..fa801a8a234
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/LegacyPhpApi/SettingsTest.php
@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\LegacyPhpApi;
+
+use Automattic\WooCommerce\Internal\Api\Main;
+use Automattic\WooCommerce\Internal\Api\Settings;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the GraphQL API Settings class on PHP versions where the
+ * dual-code GraphQL feature is unavailable (PHP < 8.1).
+ *
+ * Lives outside `tests/php/src/Internal/Api/` because that directory is
+ * excluded from the default testsuite (its dummy fixture API uses PHP 8.1+
+ * syntax). These tests must run on PHP 7.4 / 8.0 to verify that
+ * {@see Settings::add_section()} and {@see Settings::add_settings()}
+ * gracefully degrade to a no-op when {@see Main::is_enabled()} returns false.
+ *
+ * `Settings.php` and `Main.php` are intentionally PHP 7.4-parseable, and the
+ * {@see Main::is_enabled()} short-circuit prevents the methods from ever
+ * reaching the lines that reference PHP 8.1+ classes such as `GraphQLController`.
+ */
+class SettingsTest extends WC_Unit_Test_Case {
+ /**
+ * The system under test.
+ *
+ * @var Settings
+ */
+ private $sut;
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->enable_or_disable_feature( true );
+ $this->sut = new Settings();
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ $this->enable_or_disable_feature( false );
+ parent::tearDown();
+ }
+
+ /**
+ * Toggle the dual_code_graphql_api feature flag via its underlying option.
+ *
+ * @param bool $enable True to enable, false to disable.
+ */
+ private function enable_or_disable_feature( bool $enable ): void {
+ update_option(
+ wc_get_container()->get( FeaturesController::class )->feature_enable_option_name( 'dual_code_graphql_api' ),
+ $enable ? 'yes' : 'no'
+ );
+ }
+
+ /**
+ * @testdox add_section is a no-op on PHP < 8.1.
+ */
+ public function test_add_section_is_noop_on_unsupported_php(): void {
+ if ( PHP_VERSION_ID >= 80100 ) {
+ $this->markTestSkipped( 'Only relevant on PHP < 8.1.' );
+ }
+
+ $input = array( 'features' => 'Features' );
+ $result = $this->sut->add_section( $input );
+
+ $this->assertSame( $input, $result );
+ }
+
+ /**
+ * @testdox add_settings returns the input unchanged on PHP < 8.1.
+ */
+ public function test_add_settings_is_noop_on_unsupported_php(): void {
+ if ( PHP_VERSION_ID >= 80100 ) {
+ $this->markTestSkipped( 'Only relevant on PHP < 8.1.' );
+ }
+
+ $input = array( array( 'id' => 'existing' ) );
+ $result = $this->sut->add_settings( $input, Settings::SECTION_ID );
+
+ $this->assertSame( $input, $result );
+ }
+}