Commit c0e7313476f for woocommerce
commit c0e7313476fded7e4b0b0d5a5d4761bdfc98c34a
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Wed Jul 22 02:33:11 2026 +0200
Make the dual API usable for plugins without the feature flag enabled (#66706)
diff --git a/docs/apis/dual-api/README.md b/docs/apis/dual-api/README.md
index d2e86757d0d..005cfa21d74 100644
--- a/docs/apis/dual-api/README.md
+++ b/docs/apis/dual-api/README.md
@@ -23,14 +23,14 @@ This dual API, both the infrastructure and the proof of concept code API, has be
## Requirements
-- **PHP 8.1+.** The code API uses enums, named arguments, and PHP 8 attributes. On PHP 8.0 or older the GraphQL endpoint is not registered.
-- **The `dual_code_graphql_api` feature flag.** It is hidden (not shown on the Features settings page). Enable it with:
+- **PHP 8.1+.** The code API uses enums, named arguments, and PHP 8 attributes. On PHP 8.0 or older no GraphQL endpoint is registered.
+- **The `dual_code_graphql_api` feature flag, for WooCommerce core's own endpoint only.** It is hidden (not shown on the Features settings page). Enable it with:
```bash
wp option update woocommerce_feature_dual_code_graphql_api_enabled yes
```
-When the flag is off, no GraphQL route is registered. This gates **every** dual-API endpoint, the one in WooCommerce core **and** any registered by plugins. Code that touches the code API classes directly should guard on `FeaturesUtil::feature_is_enabled( 'dual_code_graphql_api' )`. The settings and filters are likewise site-wide and shared across all dual-API endpoints (see [Settings and caching](./caching-and-settings.md#scope-what-applies-where)).
+The flag gates **only** WooCommerce core's `/wc/graphql` endpoint (and its proof-of-concept code API). Endpoints registered by plugins via `Main::register_graphql_endpoint()` don't require it: they are registered whenever the required infrastructure is available (see the requirements above), regardless of the flag state. A plugin that wants an on/off switch for its endpoint provides its own mechanism, or checks the flag itself (see [Gating your endpoint](./creating-a-dual-api-in-a-plugin.md#gating-your-endpoint)). Code that touches WooCommerce core's code API classes directly should still guard on `FeaturesUtil::feature_is_enabled( 'dual_code_graphql_api' )`. The settings and filters are site-wide and shared across all dual-API endpoints (see [Settings and caching](./caching-and-settings.md#scope-what-applies-where)).
## Which document do I need?
diff --git a/docs/apis/dual-api/caching-and-settings.md b/docs/apis/dual-api/caching-and-settings.md
index cbe663f7b70..2ba22cd772a 100644
--- a/docs/apis/dual-api/caching-and-settings.md
+++ b/docs/apis/dual-api/caching-and-settings.md
@@ -6,7 +6,7 @@ sidebar_position: 6
# Settings and caching
-WooCommerce core's GraphQL endpoint is configured under **WooCommerce → Settings → Advanced → GraphQL**. The section appears only when the `dual_code_graphql_api` feature flag is on.
+WooCommerce core's GraphQL endpoint is configured under **WooCommerce → Settings → Advanced → GraphQL**. The section appears when some dual-API endpoint is active on the site: WooCommerce core's (the `dual_code_graphql_api` feature flag is on) or at least one registered by a plugin. The **Endpoint URL** setting, which only configures core's endpoint, is shown only when the feature flag is on.
These settings are **site-wide, not per-endpoint**: every setting below except **Endpoint URL** applies to *every* dual-API endpoint on the site, including those registered by plugins. See [Scope: what applies where](#scope-what-applies-where).
@@ -29,7 +29,7 @@ The depth and complexity metrics are observable on a request by appending `?_deb
The dual API has one set of switches and filters shared by every endpoint on the site, there is no per-plugin configuration surface. Concretely:
-- **The `dual_code_graphql_api` feature flag gates every dual-API endpoint.** When it's off, neither core's `/wc/graphql` nor any plugin endpoint is registered (`Main::register_graphql_endpoint()` is a no-op). PHP 8.1+ is required the same way.
+- **The `dual_code_graphql_api` feature flag gates WooCommerce core's endpoint only.** When it's off, core's `/wc/graphql` is not registered, but plugin endpoints (registered through `Main::register_graphql_endpoint()`) are unaffected: they only require the dual API infrastructure to be available. See [Gating your endpoint](./creating-a-dual-api-in-a-plugin.md#gating-your-endpoint).
- **Every setting except Endpoint URL applies to all endpoints.** The GET toggle, max depth, max complexity, the three caching toggles, and the cache TTL are read from the shared infrastructure, so a plugin endpoint honours them exactly as core's does (for example, plugin endpoints reject GET when the GET toggle is off). **Endpoint URL is the exception**: it only configures core's `/wc/graphql`; a plugin chooses its own route at registration.
- **The filters below are global.** A callback added to any of them affects *every* dual-API endpoint on the site, core and plugins alike. Each filter receives the `\WP_REST_Request`, so a callback that should apply to only one endpoint must branch on the request's route itself.
diff --git a/docs/apis/dual-api/creating-a-dual-api-in-a-plugin.md b/docs/apis/dual-api/creating-a-dual-api-in-a-plugin.md
index da544256523..c362ad4f9ea 100644
--- a/docs/apis/dual-api/creating-a-dual-api-in-a-plugin.md
+++ b/docs/apis/dual-api/creating-a-dual-api-in-a-plugin.md
@@ -12,7 +12,7 @@ A plugin can define its own code API and get a matching GraphQL endpoint using W
## Prerequisites
-- WooCommerce installed with the `dual_code_graphql_api` feature flag enabled, on PHP 8.1+.
+- WooCommerce 10.9+ on PHP 8.1+. Since WooCommerce 11.1 the `dual_code_graphql_api` feature flag is **not** required: it gates WooCommerce core's own endpoint, not plugin endpoints (see [Gating your endpoint](#gating-your-endpoint), including the note about earlier versions).
- The plugin's own Composer autoloader (PSR-4) and a `vendor/autoload.php`.
The endpoint is **dedicated**: each plugin registers its own REST route. You cannot federate into core's `/wc/graphql`.
@@ -60,13 +60,31 @@ use Automattic\WooCommerce\Api\Infrastructure\Main as WooCommerceApiMain;
add_action( 'plugins_loaded', static function () {
if ( ! method_exists( WooCommerceApiMain::class, 'register_graphql_endpoint' ) ) {
- return; // WooCommerce too old, or feature/PHP unavailable
+ return; // WooCommerce too old (no dual API infrastructure)
}
WooCommerceApiMain::register_graphql_endpoint( __DIR__, 'my-plugin', '/graphql' );
} );
```
-The first argument may be your plugin directory (the controller class is resolved by convention) or the fully-qualified controller class name. This is a no-op when the feature flag is off or PHP is < 8.1. Your endpoint goes through the same request pipeline as core's and inherits the core [GraphQL settings](./caching-and-settings.md).
+The first argument may be your plugin directory (the controller class is resolved by convention) or the fully-qualified controller class name. This is a silent no-op when the required infrastructure is not available (see [Prerequisites](#prerequisites)). Your endpoint goes through the same request pipeline as core's and inherits the core [GraphQL settings](./caching-and-settings.md).
+
+**Note:** Call `register_graphql_endpoint()` unconditionally at bootstrap, as shown above: don't wrap it in `rest_api_init`. The actual route registration is already deferred internally, and WooCommerce uses the call itself to know that a plugin endpoint exists on the site (for example, to decide whether to show the shared GraphQL settings section). A call deferred to `rest_api_init` never happens on admin, cron, or CLI requests, so that detection would break there.
+
+### Gating your endpoint
+
+The `dual_code_graphql_api` feature flag gates WooCommerce core's own endpoint only: your endpoint is registered whenever the required infrastructure is available, regardless of the flag state. Choose how (and whether) to gate it:
+
+- **Always on** (shown above): installing your plugin is what enables the endpoint. No extra code.
+- **Your own switch**: check your own option, setting, or constant before calling `register_graphql_endpoint()`.
+- **Follow the core feature flag**: opt back into the pre-11.1 behavior by checking the flag yourself:
+
+ ```php
+ if ( \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'dual_code_graphql_api' ) ) {
+ WooCommerceApiMain::register_graphql_endpoint( __DIR__, 'my-plugin', '/graphql' );
+ }
+ ```
+
+> **Before WooCommerce 11.1** (that is, on 10.9 and 11.0) the feature flag gated plugin endpoints too: with the flag off, `register_graphql_endpoint()` was a silent no-op and your endpoint was not registered. If your plugin supports those versions, your endpoint only exists there when the site owner has enabled the flag (see [Requirements](./README.md#requirements)) — regardless of which gating strategy you choose.
## 4. Reuse or replace the convention classes
diff --git a/docs/apis/dual-api/reference/infrastructure-classes.md b/docs/apis/dual-api/reference/infrastructure-classes.md
index c41db452272..4f8e297d466 100644
--- a/docs/apis/dual-api/reference/infrastructure-classes.md
+++ b/docs/apis/dual-api/reference/infrastructure-classes.md
@@ -80,8 +80,10 @@ Static helpers used by generated resolvers: exception translation, pagination co
Bootstrap and registration:
-- `is_enabled(): bool`: checks PHP 8.1+ and the `dual_code_graphql_api` flag.
-- `register_graphql_endpoint( string $plugin_dir_or_controller_class, string $route_namespace, string $route, array $methods = ['GET','POST'] ): void`: register a plugin endpoint. No-op when the feature is off.
+- `is_enabled(): bool`: whether the Dual Code & GraphQL API feature is active: the infrastructure is available and the `dual_code_graphql_api` flag is on. The flag gates WooCommerce core's own endpoint only.
+- `is_infrastructure_available(): bool`: whether the server can run the dual API infrastructure. That's all that plugin endpoint registration requires.
+- `has_registered_plugin_endpoints(): bool`: whether some plugin has registered an endpoint during the current request.
+- `register_graphql_endpoint( string $plugin_dir_or_controller_class, string $route_namespace, string $route, array $methods = ['GET','POST'] ): void`: register a plugin endpoint. Silent no-op when the infrastructure is unavailable; doesn't depend on the feature flag.
- `instantiate_graphql_controller( string $controller_class_name ): ?GraphQLControllerBase`.
### `MetadataController`, `QueryInfoExtractor`
diff --git a/plugins/woocommerce/changelog/make-dual-api-usable-for-plugins-without-feature-enabled b/plugins/woocommerce/changelog/make-dual-api-usable-for-plugins-without-feature-enabled
new file mode 100644
index 00000000000..7ff8876ac82
--- /dev/null
+++ b/plugins/woocommerce/changelog/make-dual-api-usable-for-plugins-without-feature-enabled
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Dual API: endpoints registered by plugins no longer require the dual_code_graphql_api feature flag to be active.
diff --git a/plugins/woocommerce/src/Api/Infrastructure/Main.php b/plugins/woocommerce/src/Api/Infrastructure/Main.php
index 6cce34fea27..b88c3afa39c 100644
--- a/plugins/woocommerce/src/Api/Infrastructure/Main.php
+++ b/plugins/woocommerce/src/Api/Infrastructure/Main.php
@@ -84,18 +84,53 @@ class Main {
*/
public const OPTION_QUERY_CACHE_TTL = 'woocommerce_graphql_query_cache_ttl';
+ /**
+ * Whether at least one plugin endpoint has been registered via
+ * register_graphql_endpoint().
+ *
+ * @var bool
+ */
+ private static $plugin_endpoints_registered = false;
+
/**
* Check whether the Dual Code & GraphQL API feature is active.
*
* Requires PHP 8.1+ and the dual_code_graphql_api feature flag to be
* enabled.
*
+ * The flag gates WooCommerce core's own GraphQL endpoint only. Endpoints
+ * registered by plugins via register_graphql_endpoint() should check
+ * is_infrastructure_available() instead.
+ *
* @return bool
*/
public static function is_enabled(): bool {
return PHP_VERSION_ID >= 80100 && FeaturesUtil::feature_is_enabled( self::FEATURE_SLUG );
}
+ /**
+ * Check whether the dual API infrastructure can run on this server.
+ *
+ * @return bool
+ *
+ * @since 11.1.0
+ */
+ public static function is_infrastructure_available(): bool {
+ return PHP_VERSION_ID >= 80100;
+ }
+
+ /**
+ * Whether at least one plugin endpoint has been registered via
+ * register_graphql_endpoint() during the current request.
+ *
+ * @return bool
+ *
+ * @since 11.1.0
+ */
+ public static function has_registered_plugin_endpoints(): bool {
+ return self::$plugin_endpoints_registered;
+ }
+
/**
* Whether the GraphQL endpoint accepts GET requests.
*
@@ -204,16 +239,17 @@ class Main {
* into their own autogenerated namespace). The returned controller is
* ready to have handle_request() attached to a REST route.
*
- * Returns null when the feature flag is off or PHP is < 8.1, so callers
- * can invoke this unconditionally from inside their own rest_api_init
- * handler.
+ * Returns null when is_infrastructure_available() returns false, so callers
+ * can invoke this unconditionally from inside their own rest_api_init handler.
+ * The dual_code_graphql_api feature flag doesn't apply here: it gates
+ * WooCommerce core's own endpoint only.
*
* @param string $controller_class_name Fully-qualified name of a subclass of GraphQLControllerBase.
*
* @throws \InvalidArgumentException If $controller_class_name does not extend GraphQLControllerBase.
*/
public static function instantiate_graphql_controller( string $controller_class_name ): ?GraphQLControllerBase {
- if ( ! self::is_enabled() ) {
+ if ( ! self::is_infrastructure_available() ) {
return null;
}
@@ -227,14 +263,26 @@ class Main {
/**
* Register a GraphQL REST endpoint backed by a plugin-provided controller subclass.
*
- * May be called at any time up to and including the `rest_api_init` hook:
- * if called earlier, registration is deferred to that hook; if called from
- * inside another plugin's `rest_api_init` handler, registration happens
- * immediately. Calls made after `rest_api_init` has already completed
- * register a REST route that WP_REST_Server won't honour on the current
- * request — callers should avoid deferring registration past bootstrap.
- *
- * When the feature flag is off or PHP is < 8.1 this is a silent no-op.
+ * Call this unconditionally from the plugin's bootstrap code (e.g. on
+ * `plugins_loaded` or `init`), in every request context: the actual REST
+ * route registration is deferred internally to the `rest_api_init` hook,
+ * so there's no reason to wrap the call in that hook yourself. Deferring
+ * it means the call never runs in contexts where `rest_api_init` doesn't
+ * fire (admin screens, cron, Action Scheduler, CLI), so in those contexts
+ * has_registered_plugin_endpoints() would stay false and, while core's own
+ * endpoint is disabled, the GraphQL settings section would not be shown.
+ *
+ * Late calls still register the endpoint: from inside another plugin's
+ * `rest_api_init` handler registration happens immediately, while calls
+ * made after `rest_api_init` has already completed register a REST route
+ * that WP_REST_Server won't honour on the current request.
+ *
+ * When is_infrastructure_available() returns false this is a silent no-op.
+ * The dual_code_graphql_api feature flag doesn't gate plugin endpoints:
+ * they are registered regardless of the flag state. A plugin that wants an
+ * on/off switch for its endpoint should provide its own mechanism, or check
+ * the flag itself with FeaturesUtil::feature_is_enabled( 'dual_code_graphql_api' )
+ * before calling this method.
*
* The first argument accepts either of two forms:
*
@@ -269,7 +317,7 @@ class Main {
string $route,
array $methods = array( 'GET', 'POST' )
): void {
- if ( ! self::is_enabled() ) {
+ if ( ! self::is_infrastructure_available() ) {
return;
}
@@ -279,6 +327,8 @@ class Main {
// deep inside the deferred rest_api_init callback.
self::assert_is_controller_subclass( $controller_class_name );
+ self::$plugin_endpoints_registered = true;
+
$registrar = new GraphQLEndpointRegistrar( $controller_class_name, $route_namespace, $route, $methods );
if ( did_action( 'rest_api_init' ) ) {
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
index a3da661a0ea..33257b0dfd3 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
@@ -1 +1 @@
-2026-05-21T10:29:44+00:00
\ No newline at end of file
+2026-07-16T08:54:12+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
index 59f41b2e8ec..a8c80ea1623 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
@@ -1 +1 @@
-2965921ea12d55aff3aa621ff023f57cf0dde87517b5c2fabf7ad47752f8a128
\ No newline at end of file
+838fede51b1dcec38371ad1789d4907e2bbeedb8fe80152807d4a27928c3e13a
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/OpcacheFileExpiry.php b/plugins/woocommerce/src/Internal/Api/OpcacheFileExpiry.php
index e115cec8d89..9fea2a86fed 100644
--- a/plugins/woocommerce/src/Internal/Api/OpcacheFileExpiry.php
+++ b/plugins/woocommerce/src/Internal/Api/OpcacheFileExpiry.php
@@ -65,14 +65,17 @@ class OpcacheFileExpiry {
* Action Scheduler callback: delete expired files and reschedule.
*
* Immediate reschedule when files were deleted (drain the backlog), 24h
- * otherwise. Skipped when the feature is disabled.
+ * otherwise. Skipped when no dual-API endpoint is active (core's feature
+ * flag off and no plugin endpoints registered), since then nothing writes
+ * to the cache; {@see self::ensure_scheduled()} restarts the cycle when
+ * an endpoint becomes active again.
*
* @internal
*/
public static function handle_cleanup_action(): void {
$interval = self::delete_expired_files() > 0 ? 1 : DAY_IN_SECONDS;
- if ( ! Main::is_enabled() ) {
+ if ( ! Main::is_enabled() && ! Main::has_registered_plugin_endpoints() ) {
return;
}
diff --git a/plugins/woocommerce/src/Internal/Api/Settings.php b/plugins/woocommerce/src/Internal/Api/Settings.php
index aa7bb8594de..08965799173 100644
--- a/plugins/woocommerce/src/Internal/Api/Settings.php
+++ b/plugins/woocommerce/src/Internal/Api/Settings.php
@@ -11,8 +11,10 @@ use Automattic\WooCommerce\Api\Infrastructure\Main;
* Settings handling for the GraphQL API.
*
* Registers the "GraphQL" section under WooCommerce - Settings - Advanced.
- * Only active when Main::is_enabled() returns true (feature flag on and
- * PHP 8.1+), so the section is hidden when the feature is disabled.
+ * The section is shown when some dual-API endpoint is active: WooCommerce
+ * core's (Main::is_enabled(), feature flag on and PHP 8.1+) or at least one
+ * registered by a plugin (which doesn't require the feature flag). All the
+ * settings except Endpoint URL apply to plugin endpoints too.
*/
class Settings {
/**
@@ -41,12 +43,23 @@ class Settings {
* @return array
*/
public function add_section( array $sections ): array {
- if ( Main::is_enabled() ) {
+ if ( $this->should_display_section() ) {
$sections[ self::SECTION_ID ] = __( 'GraphQL', 'woocommerce' );
}
return $sections;
}
+ /**
+ * Whether the GraphQL settings section should be shown: some dual-API
+ * endpoint (core's or a plugin's) must be active for these settings to
+ * have an effect.
+ *
+ * @return bool
+ */
+ private function should_display_section(): bool {
+ return Main::is_enabled() || Main::has_registered_plugin_endpoints();
+ }
+
/**
* Provide the settings fields for the GraphQL section.
*
@@ -55,25 +68,34 @@ class Settings {
* @return array
*/
public function add_settings( array $settings, string $section_id ): array {
- if ( self::SECTION_ID !== $section_id || ! Main::is_enabled() ) {
+ if ( self::SECTION_ID !== $section_id || ! $this->should_display_section() ) {
return $settings;
}
- return array(
+ $settings = array(
array(
'title' => __( 'GraphQL', 'woocommerce' ),
'desc' => __( 'Configure the WooCommerce GraphQL API.', 'woocommerce' ),
'type' => 'title',
'id' => 'woocommerce_graphql_options',
),
- array(
+ );
+
+ // The Endpoint URL setting only configures core's own endpoint, so it's
+ // meaningless when that endpoint is off (feature flag disabled) and only
+ // plugin-registered endpoints, which choose their own route, are active.
+ if ( Main::is_enabled() ) {
+ $settings[] = array(
'title' => __( 'Endpoint URL', 'woocommerce' ),
'desc' => __( 'Path relative to /wp-json/ where the GraphQL endpoint is exposed. Needs at least two segments (namespace/route), e.g. wc/graphql.', 'woocommerce' ),
'desc_tip' => true,
'id' => Main::OPTION_ENDPOINT_URL,
'default' => GraphQLControllerBase::DEFAULT_ENDPOINT_URL,
'type' => 'text',
- ),
+ );
+ }
+
+ $shared_settings = array(
array(
'title' => __( 'Enable GET endpoint', 'woocommerce' ),
'desc' => __( 'Allow GraphQL queries over GET in addition to POST', 'woocommerce' ),
@@ -131,6 +153,8 @@ class Settings {
'id' => 'woocommerce_graphql_options',
),
);
+
+ return array_merge( $settings, $shared_settings );
}
/**
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index a12b1d51c7a..1e237ab73da 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -637,7 +637,7 @@ class FeaturesController {
'dual_code_graphql_api' => array(
'name' => __( 'Dual Code & GraphQL API', 'woocommerce' ),
'description' => __(
- 'Experimental code-first API for WooCommerce with automatic GraphQL endpoint generation. Requires PHP 8.1 or later.',
+ 'Enable the WooCommerce core GraphQL endpoint of the experimental code-first dual API. Endpoints registered by plugins are not affected by this flag. Requires PHP 8.1 or later.',
'woocommerce'
),
'enabled_by_default' => false,
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLEndpointRegistrarTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLEndpointRegistrarTest.php
index 35dcde85878..90e4d23edd3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLEndpointRegistrarTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLEndpointRegistrarTest.php
@@ -108,9 +108,9 @@ class GraphQLEndpointRegistrarTest extends WC_REST_Unit_Test_Case {
}
/**
- * @testdox handle_rest_api_init skips registration when the feature is disabled.
+ * @testdox handle_rest_api_init registers the route even when the feature is disabled.
*/
- public function test_handle_rest_api_init_skips_registration_when_feature_is_off(): void {
+ public function test_handle_rest_api_init_registers_route_when_feature_is_off(): void {
$this->enable_or_disable_feature( false );
$registrar = new GraphQLEndpointRegistrar(
@@ -123,6 +123,10 @@ class GraphQLEndpointRegistrarTest extends WC_REST_Unit_Test_Case {
$registrar->handle_rest_api_init();
$routes = rest_get_server()->get_routes();
- $this->assertArrayNotHasKey( '/wc-test/disabled-feature-route', $routes );
+ $this->assertArrayHasKey(
+ '/wc-test/disabled-feature-route',
+ $routes,
+ 'Plugin endpoints should be registered regardless of the feature flag state.'
+ );
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/MainTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/MainTest.php
index 2eeab68f1b8..50906b581f6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/MainTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/MainTest.php
@@ -11,9 +11,9 @@ 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.
+ * Tests for {@see Main} — the entry point that gates core endpoint registration
+ * on PHP + feature flag and plugin endpoint registration on PHP only, exposes
+ * the GET-endpoint setting, and validates plugin-supplied controller arguments.
*/
class MainTest extends WC_Unit_Test_Case {
/**
@@ -30,9 +30,20 @@ class MainTest extends WC_Unit_Test_Case {
public function tearDown(): void {
$this->enable_or_disable_feature( false );
delete_option( Main::OPTION_GET_ENDPOINT_ENABLED );
+ $this->reset_plugin_endpoints_registered();
parent::tearDown();
}
+ /**
+ * Reset the static plugin endpoint registration flag in Main,
+ * which otherwise persists across tests.
+ */
+ private function reset_plugin_endpoints_registered(): void {
+ $property = ( new \ReflectionClass( Main::class ) )->getProperty( 'plugin_endpoints_registered' );
+ $property->setAccessible( true );
+ $property->setValue( null, false );
+ }
+
/**
* Toggle the dual_code_graphql_api feature flag via its underlying option.
*
@@ -107,11 +118,26 @@ class MainTest extends WC_Unit_Test_Case {
}
/**
- * @testdox instantiate_graphql_controller returns null when the feature is disabled.
+ * @testdox is_infrastructure_available returns true on PHP 8.1+ regardless of the feature flag.
*/
- public function test_instantiate_returns_null_when_disabled(): void {
+ public function test_is_infrastructure_available_ignores_the_feature_flag(): void {
+ if ( PHP_VERSION_ID < 80100 ) {
+ $this->markTestSkipped( 'The dual API infrastructure requires PHP 8.1+.' );
+ }
+
$this->enable_or_disable_feature( false );
- $this->assertNull( Main::instantiate_graphql_controller( AutogeneratedGraphQLController::class ) );
+ $this->assertTrue( Main::is_infrastructure_available() );
+ }
+
+ /**
+ * @testdox instantiate_graphql_controller returns a controller even when the feature is disabled.
+ */
+ public function test_instantiate_returns_controller_when_feature_is_off(): void {
+ $this->enable_or_disable_feature( false );
+
+ $controller = Main::instantiate_graphql_controller( AutogeneratedGraphQLController::class );
+
+ $this->assertInstanceOf( GraphQLControllerBase::class, $controller );
}
/**
@@ -140,17 +166,19 @@ class MainTest extends WC_Unit_Test_Case {
}
/**
- * @testdox register_graphql_endpoint is a silent no-op when the feature is disabled.
+ * @testdox register_graphql_endpoint accepts registrations even when the feature is disabled.
*/
- public function test_register_graphql_endpoint_is_a_no_op_when_disabled(): void {
+ public function test_register_graphql_endpoint_works_when_feature_is_off(): void {
$this->enable_or_disable_feature( false );
- $routes_before = rest_get_server()->get_routes();
+ $this->assertFalse( Main::has_registered_plugin_endpoints() );
- // Must not throw and must leave the REST route map unchanged.
- Main::register_graphql_endpoint( AutogeneratedGraphQLController::class, 'wc-test', '/no-op' );
+ Main::register_graphql_endpoint( AutogeneratedGraphQLController::class, 'wc-test', '/feature-off' );
- $this->assertSame( $routes_before, rest_get_server()->get_routes() );
+ $this->assertTrue(
+ Main::has_registered_plugin_endpoints(),
+ 'The endpoint registration should be recorded regardless of the feature flag state.'
+ );
}
/**
@@ -247,6 +275,14 @@ class MainTest extends WC_Unit_Test_Case {
$prop->setAccessible( true );
$original_endpoints = $prop->getValue( $server );
+ // rest_get_server() fires rest_api_init when it creates the server,
+ // which would suppress the expected notice; whether that happens here
+ // depends on which tests ran before this one. Clear the action count
+ // so the notice fires deterministically (the test base class restores
+ // the counts on tear down).
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ unset( $GLOBALS['wp_actions']['rest_api_init'] );
+
try {
// Force reset so we can observe registration deterministically.
$endpoints = $original_endpoints;
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/OpcacheFileExpiryTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/OpcacheFileExpiryTest.php
index 797dcf66d76..d1af99ac951 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/OpcacheFileExpiryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/OpcacheFileExpiryTest.php
@@ -3,8 +3,10 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\Api;
+use Automattic\WooCommerce\Api\Infrastructure\Main;
use Automattic\WooCommerce\Internal\Api\OpcacheFileExpiry;
use Automattic\WooCommerce\Internal\Api\QueryCache;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
use WC_Unit_Test_Case;
/**
@@ -42,9 +44,35 @@ class OpcacheFileExpiryTest extends WC_Unit_Test_Case {
if ( function_exists( 'as_unschedule_all_actions' ) ) {
as_unschedule_all_actions( OpcacheFileExpiry::ACTION_HOOK );
}
+ $this->enable_or_disable_feature( false );
+ $this->set_plugin_endpoints_registered( false );
parent::tearDown();
}
+ /**
+ * Enable or disable the dual_code_graphql_api feature 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'
+ );
+ }
+
+ /**
+ * Set the static plugin endpoint registration flag in Main, normally
+ * set by Main::register_graphql_endpoint().
+ *
+ * @param bool $registered The value to set.
+ */
+ private function set_plugin_endpoints_registered( bool $registered ): void {
+ $property = ( new \ReflectionClass( Main::class ) )->getProperty( 'plugin_endpoints_registered' );
+ $property->setAccessible( true );
+ $property->setValue( null, $registered );
+ }
+
/**
* @testdox delete_expired_files removes only files whose mtime is older than the TTL.
*/
@@ -78,6 +106,61 @@ class OpcacheFileExpiryTest extends WC_Unit_Test_Case {
$this->assertSame( 0, OpcacheFileExpiry::delete_expired_files() );
}
+ /**
+ * @testdox handle_cleanup_action does not reschedule when no dual-API endpoint is active.
+ */
+ public function test_handle_cleanup_action_does_not_reschedule_when_no_endpoint_is_active(): void {
+ if ( ! function_exists( 'as_has_scheduled_action' ) ) {
+ $this->markTestSkipped( 'Action Scheduler is not available.' );
+ }
+
+ as_unschedule_all_actions( OpcacheFileExpiry::ACTION_HOOK );
+ $this->enable_or_disable_feature( false );
+
+ OpcacheFileExpiry::handle_cleanup_action();
+
+ $this->assertFalse(
+ as_has_scheduled_action( OpcacheFileExpiry::ACTION_HOOK, array(), OpcacheFileExpiry::ACTION_GROUP ),
+ 'The cleanup should not be rescheduled when nothing writes to the cache.'
+ );
+ }
+
+ /**
+ * @testdox handle_cleanup_action reschedules when the feature is off but a plugin endpoint is registered.
+ */
+ public function test_handle_cleanup_action_reschedules_when_plugin_endpoints_exist(): void {
+ if ( ! function_exists( 'as_has_scheduled_action' ) ) {
+ $this->markTestSkipped( 'Action Scheduler is not available.' );
+ }
+
+ $this->enable_or_disable_feature( false );
+ $this->set_plugin_endpoints_registered( true );
+
+ OpcacheFileExpiry::handle_cleanup_action();
+
+ $this->assertTrue(
+ as_has_scheduled_action( OpcacheFileExpiry::ACTION_HOOK, array(), OpcacheFileExpiry::ACTION_GROUP ),
+ 'The cleanup should be rescheduled while plugin endpoints use the cache, regardless of the feature flag.'
+ );
+ }
+
+ /**
+ * @testdox handle_cleanup_action reschedules when the feature is enabled.
+ */
+ public function test_handle_cleanup_action_reschedules_when_feature_is_on(): void {
+ if ( ! function_exists( 'as_has_scheduled_action' ) ) {
+ $this->markTestSkipped( 'Action Scheduler is not available.' );
+ }
+
+ $this->enable_or_disable_feature( true );
+
+ OpcacheFileExpiry::handle_cleanup_action();
+
+ $this->assertTrue(
+ as_has_scheduled_action( OpcacheFileExpiry::ACTION_HOOK, array(), OpcacheFileExpiry::ACTION_GROUP )
+ );
+ }
+
/**
* Create a per-test cache directory, point the OPcache filter at it, and
* register it for cleanup in tearDown.
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
index f8e6569a996..03c328ba3cd 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
@@ -43,6 +43,7 @@ class SettingsTest extends WC_Unit_Test_Case {
);
delete_option( Main::OPTION_ENDPOINT_URL );
$this->enable_or_disable_feature( false );
+ $this->set_plugin_endpoints_registered( false );
parent::tearDown();
}
@@ -58,6 +59,18 @@ class SettingsTest extends WC_Unit_Test_Case {
);
}
+ /**
+ * Set the static plugin endpoint registration flag in Main, normally
+ * set by Main::register_graphql_endpoint().
+ *
+ * @param bool $registered The value to set.
+ */
+ private function set_plugin_endpoints_registered( bool $registered ): void {
+ $property = ( new \ReflectionClass( Main::class ) )->getProperty( 'plugin_endpoints_registered' );
+ $property->setAccessible( true );
+ $property->setValue( null, $registered );
+ }
+
/**
* @testdox register hooks add_section and add_settings into WooCommerce's advanced settings filters.
*/
@@ -277,7 +290,7 @@ class SettingsTest extends WC_Unit_Test_Case {
}
/**
- * @testdox add_section returns sections unchanged when the feature is disabled.
+ * @testdox add_section returns sections unchanged when the feature is disabled and no plugin endpoints are registered.
*/
public function test_add_section_does_not_register_when_feature_is_off(): void {
$this->enable_or_disable_feature( false );
@@ -288,7 +301,7 @@ class SettingsTest extends WC_Unit_Test_Case {
}
/**
- * @testdox add_settings returns settings unchanged when the feature is disabled.
+ * @testdox add_settings returns settings unchanged when the feature is disabled and no plugin endpoints are registered.
*/
public function test_add_settings_does_not_register_when_feature_is_off(): void {
$this->enable_or_disable_feature( false );
@@ -298,6 +311,41 @@ class SettingsTest extends WC_Unit_Test_Case {
$this->assertSame( array(), $result );
}
+ /**
+ * @testdox add_section appends the graphql section when the feature is disabled but a plugin endpoint is registered.
+ */
+ public function test_add_section_registers_when_feature_is_off_but_plugin_endpoints_exist(): void {
+ $this->enable_or_disable_feature( false );
+ $this->set_plugin_endpoints_registered( true );
+
+ $result = $this->sut->add_section( array( 'features' => 'Features' ) );
+
+ $this->assertArrayHasKey( Settings::SECTION_ID, $result );
+ }
+
+ /**
+ * @testdox add_settings omits the endpoint URL field but keeps the shared fields when the feature is disabled but a plugin endpoint is registered.
+ */
+ public function test_add_settings_omits_endpoint_url_when_feature_is_off_but_plugin_endpoints_exist(): void {
+ $this->enable_or_disable_feature( false );
+ $this->set_plugin_endpoints_registered( true );
+
+ $fields = $this->sut->add_settings( array(), Settings::SECTION_ID );
+ $by_id = array_column( $fields, null, 'id' );
+
+ $this->assertArrayNotHasKey(
+ Main::OPTION_ENDPOINT_URL,
+ $by_id,
+ 'The endpoint URL field only configures core\'s endpoint, so it should be hidden when the feature is off.'
+ );
+ $this->assertArrayHasKey(
+ Main::OPTION_GET_ENDPOINT_ENABLED,
+ $by_id,
+ 'Shared settings apply to plugin endpoints too, so they should still be shown.'
+ );
+ $this->assertArrayHasKey( Main::OPTION_QUERY_CACHE_TTL, $by_id );
+ }
+
/**
* @testdox add_settings defines the parsed query cache TTL field with min=1 and the default constant as default.
*/