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 );
+	}
+}