Commit 118aa6d79a6 for woocommerce

commit 118aa6d79a692b3ebbf3104f688b559ca4bf5913
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Fri May 8 09:19:14 2026 +0200

    Flexibilize the dual API response status codes (#64699)

    Add an optional `HttpStatusResolver` convention class plugins can ship
    at `<api root>\Infrastructure\HttpStatusResolver` with a
    `resolve_status(int $default_status, array $output, \WP_REST_Request $request): int`
    method, routed through every status-decision point in GraphQLController
    via a new `pick_status()` helper. Returning `$default_status` defers to
    the framework's existing per-error-code mapping; non-200 returns
    override it. Resolver-thrown errors are caught and turned into a clean
    500 INTERNAL_ERROR without re-invoking the resolver.

diff --git a/plugins/woocommerce/changelog/flexibilize-graphql-status-codes b/plugins/woocommerce/changelog/flexibilize-graphql-status-codes
new file mode 100644
index 00000000000..09132ca3925
--- /dev/null
+++ b/plugins/woocommerce/changelog/flexibilize-graphql-status-codes
@@ -0,0 +1,4 @@
+Significance: minor
+Type: dev
+
+Add extra flexibility to the HTTP status code computation of the dual code+GraphQL API.
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
index e3c2a57f050..0a2ea5661e9 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
@@ -170,6 +170,9 @@ class ApiBuilder {
 	/** @var ?string Optional FQCN of a user-provided `<api_namespace>\Infrastructure\PrincipalResolver` class; null when absent. */
 	private ?string $principal_resolver_fqcn = null;

+	/** @var ?string Optional FQCN of a user-provided `<api_namespace>\Infrastructure\HttpStatusResolver` class with a public `resolve_status(int, array, \WP_REST_Request): int` method; null when absent. */
+	private ?string $status_resolver_fqcn = null;
+
 	/**
 	 * Whether the detected PrincipalResolver's `resolve_principal()` declares the
 	 * \WP_REST_Request parameter (true) or omits it (false).
@@ -223,6 +226,7 @@ class ApiBuilder {

 		$this->detect_class_resolver();
 		$this->detect_principal_resolver();
+		$this->detect_status_resolver();
 		$this->discover_authorization_attributes();
 		$this->discover();
 		$this->validate();
@@ -246,9 +250,27 @@ class ApiBuilder {

 		if ( null !== $this->composer_working_dir ) {
 			echo "Regenerating autoloader...\n";
-			exec( 'composer dump-autoload --working-dir=' . escapeshellarg( $this->composer_working_dir ) . ' 2>&1', $output, $code );
-			if ( $code !== 0 ) {
-				echo 'Warning: composer dump-autoload failed: ' . implode( "\n", $output ) . "\n";
+			// Use chdir() + passthru() instead of exec() with --working-dir.
+			// Some composer post-autoload-dump hooks — notably the Jetpack
+			// autoloader's merged-manifest step that WooCommerce relies on
+			// at runtime — read getcwd() rather than honouring composer's
+			// resolved --working-dir, and silently produce stale manifests
+			// when the two don't match. Setting the actual process CWD
+			// keeps every hook on the same dir; passthru() also streams
+			// output so any failure mode is visible to the developer
+			// rather than being swallowed into a captured array.
+			$original_cwd = getcwd();
+			if ( false === chdir( $this->composer_working_dir ) ) {
+				echo "Warning: could not chdir to {$this->composer_working_dir}; skipping autoloader regeneration.\n";
+			} else {
+				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_passthru -- design-time CLI script; never runs in a web context.
+				passthru( 'composer dump-autoload', $code );
+				if ( false !== $original_cwd ) {
+					chdir( $original_cwd );
+				}
+				if ( 0 !== $code ) {
+					echo "Warning: composer dump-autoload exited with code {$code}.\n";
+				}
 			}
 		}

@@ -415,6 +437,70 @@ class ApiBuilder {
 		echo "  Using principal resolver: {$candidate} (principal type: {$return_type_name}).\n";
 	}

+	// ========================================================================
+	// HTTP status resolver detection
+	// ========================================================================
+
+	/**
+	 * Detect an optional plugin-supplied HTTP status resolver.
+	 *
+	 * If a class `<api_namespace>\Infrastructure\HttpStatusResolver` exists
+	 * and exposes a `public function resolve_status( int, array,
+	 * \WP_REST_Request ): int` instance method, the autogenerated
+	 * GraphQLController subclass overrides `get_status_resolver()` to return
+	 * an instance of it. The base controller then routes every status
+	 * decision through that instance, letting plugins override status codes
+	 * (e.g. Shopify-style "always 200") without touching the framework.
+	 *
+	 * Plugins that omit this class get the framework defaults unchanged.
+	 * WooCommerce core does not ship a default class.
+	 */
+	private function detect_status_resolver(): void {
+		$candidate = $this->api_namespace . '\\Infrastructure\\HttpStatusResolver';
+		if ( ! class_exists( $candidate ) ) {
+			return;
+		}
+		if ( ! method_exists( $candidate, 'resolve_status' ) ) {
+			$this->warnings[] = "HttpStatusResolver class {$candidate} has no resolve_status() method; ignoring.";
+			return;
+		}
+		$method = new \ReflectionMethod( $candidate, 'resolve_status' );
+		if ( ! $method->isPublic() || $method->isStatic() ) {
+			$this->warnings[] = "HttpStatusResolver class {$candidate}::resolve_status() must be a public instance method; ignoring.";
+			return;
+		}
+
+		// Validate `resolve_status(int, array, \WP_REST_Request): int`. A
+		// wrong signature would slip past these checks and surface as a
+		// TypeError deep inside pick_status() at request time, far from its
+		// cause.
+		$params      = $method->getParameters();
+		$return_type = $method->getReturnType();
+
+		$is_required_named_type = static function ( ?\ReflectionParameter $param, string $expected ): bool {
+			if ( null === $param || $param->isVariadic() || $param->isOptional() ) {
+				return false;
+			}
+			$type = $param->getType();
+			return $type instanceof \ReflectionNamedType && ltrim( $type->getName(), '\\' ) === $expected;
+		};
+
+		$signature_ok = 3 === count( $params )
+			&& $is_required_named_type( $params[0], 'int' )
+			&& $is_required_named_type( $params[1], 'array' )
+			&& $is_required_named_type( $params[2], 'WP_REST_Request' )
+			&& $return_type instanceof \ReflectionNamedType
+			&& 'int' === $return_type->getName();
+
+		if ( ! $signature_ok ) {
+			$this->warnings[] = "HttpStatusResolver class {$candidate}::resolve_status() must have signature `resolve_status(int, array, \\WP_REST_Request): int`; ignoring.";
+			return;
+		}
+
+		$this->status_resolver_fqcn = $candidate;
+		echo "  Using HTTP status resolver: {$candidate}.\n";
+	}
+
 	// ========================================================================
 	// Authorization attribute discovery
 	// ========================================================================
@@ -2114,6 +2200,7 @@ class ApiBuilder {
 				'class_resolver_fqcn'              => $this->class_resolver_fqcn,
 				'principal_resolver_fqcn'          => $this->principal_resolver_fqcn,
 				'principal_resolver_takes_request' => $this->principal_resolver_takes_request,
+				'status_resolver_fqcn'             => $this->status_resolver_fqcn,
 			)
 		);

diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/GraphQLControllerTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/GraphQLControllerTemplate.php
index 41759271593..b2d50d5b903 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/GraphQLControllerTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/GraphQLControllerTemplate.php
@@ -7,16 +7,18 @@
  * build_schema() to reference the root types that ApiBuilder has just
  * generated in the same autogenerated namespace.
  *
- * When the build detected a `<api_namespace>\Infrastructure\ClassResolver` or
- * `<api_namespace>\Infrastructure\PrincipalResolver` convention class, this
- * template also emits an override that returns the FQCN, so the base
- * controller routes class instantiation and per-request principal
- * resolution through it.
+ * When the build detected a `<api_namespace>\Infrastructure\ClassResolver`,
+ * `<api_namespace>\Infrastructure\PrincipalResolver`, or
+ * `<api_namespace>\Infrastructure\HttpStatusResolver` convention class, this
+ * template also emits an override that wires it into the base controller —
+ * routing class instantiation, per-request principal resolution, and
+ * HTTP-status decisions respectively.
  *
  * @var string  $namespace
  * @var ?string $class_resolver_fqcn              - FQCN of the detected ClassResolver, or null.
  * @var ?string $principal_resolver_fqcn          - FQCN of the detected PrincipalResolver, or null.
  * @var bool    $principal_resolver_takes_request - true when resolve_principal() declares its \WP_REST_Request parameter; false when zero-arg.
+ * @var ?string $status_resolver_fqcn             - FQCN of the detected HttpStatusResolver, or null.
  */
 ?>
 <?php echo '<?php'; ?>
@@ -55,4 +57,10 @@ class GraphQLController extends \Automattic\WooCommerce\Internal\Api\GraphQLCont
 		return <?php echo $principal_resolver_takes_request ? 'true' : 'false'; ?>;
 	}
 <?php endif; ?>
+<?php if ( null !== $status_resolver_fqcn ) : ?>
+
+	protected function get_status_resolver(): ?object {
+		return new \<?php echo $status_resolver_fqcn; ?>();
+	}
+<?php endif; ?>
 }
diff --git a/plugins/woocommerce/src/Internal/Api/GraphQLController.php b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
index d8bc4f9b161..8782e0d71d1 100644
--- a/plugins/woocommerce/src/Internal/Api/GraphQLController.php
+++ b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
@@ -70,6 +70,22 @@ abstract class GraphQLController {
 	 */
 	private QueryCache $query_cache;

+	/**
+	 * Optional plugin-supplied HTTP status resolver.
+	 *
+	 * Populated from {@see self::get_status_resolver()} during {@see self::init()}.
+	 * Stays null when neither this controller nor its subclass supplies one,
+	 * in which case {@see self::pick_status()} short-circuits to the default
+	 * status without ever calling a resolver.
+	 *
+	 * Typed as `?object` rather than a WooCommerce-defined interface so that
+	 * sibling plugins do not have to import a WooCommerce type for what is
+	 * structurally a single duck-typed method.
+	 *
+	 * @var ?object
+	 */
+	private ?object $status_resolver = null;
+
 	/**
 	 * DI: injected by WooCommerce container.
 	 *
@@ -78,6 +94,26 @@ abstract class GraphQLController {
 	 */
 	final public function init( QueryCache $query_cache ): void {
 		$this->query_cache = $query_cache;
+		// Resolved through a virtual hook so autogenerated subclasses can
+		// supply a per-plugin resolver without changing init()'s signature.
+		// Late binding picks up the override; init() can stay final.
+		$this->status_resolver = $this->get_status_resolver();
+	}
+
+	/**
+	 * Return the HTTP status resolver instance to use for this controller, or
+	 * null to opt out. Default: null (use the framework defaults).
+	 *
+	 * Autogenerated subclasses override this when the plugin ships a
+	 * `<plugin-api-namespace>\Infrastructure\HttpStatusResolver` convention
+	 * class. The returned object is duck-typed: it must expose
+	 * `public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int`,
+	 * must return an int, and must not throw. A throw is treated as a plugin
+	 * bug and produces a fixed 500 INTERNAL_ERROR response — see
+	 * {@see self::pick_status()} and {@see self::handle_request()}.
+	 */
+	protected function get_status_resolver(): ?object {
+		return null;
 	}

 	/**
@@ -202,6 +238,11 @@ abstract class GraphQLController {
 		try {
 			$principal = $this->resolve_request_principal( $request );
 			return $this->process_request( $request, $principal );
+		} catch ( StatusResolverFailedException $e ) {
+			// Resolver threw on one of the decision points inside
+			// process_request(). Produce a clean 500 without re-invoking
+			// the (broken) resolver.
+			return $this->build_resolver_failure_response();
 		} catch ( \Throwable $e ) {
 			$output = array(
 				'errors' => array(
@@ -209,11 +250,81 @@ abstract class GraphQLController {
 				),
 			);

-			$status = $this->get_error_status( $output['errors'] );
+			$default = $this->get_error_status( $output['errors'] );
+			try {
+				$status = $this->pick_status( $default, $output, $request );
+			} catch ( StatusResolverFailedException $e2 ) {
+				// Resolver threw specifically when handed the synthetic
+				// errors shape from this catch block. Fall through to the
+				// fixed 500; do not loop back into the resolver.
+				return $this->build_resolver_failure_response();
+			}
+
 			return new \WP_REST_Response( $output, $status );
 		}
 	}

+	/**
+	 * Build the canonical 500 response used when the HTTP status resolver
+	 * throws. Body shape matches an unhandled internal error so callers
+	 * don't need a separate path for "resolver blew up".
+	 */
+	private function build_resolver_failure_response(): \WP_REST_Response {
+		return new \WP_REST_Response(
+			array(
+				'errors' => array(
+					array(
+						'message'    => 'An unexpected error occurred.',
+						'extensions' => array( 'code' => 'INTERNAL_ERROR' ),
+					),
+				),
+			),
+			500
+		);
+	}
+
+	/**
+	 * Filter the framework-computed default HTTP status through the optional
+	 * plugin-supplied status resolver.
+	 *
+	 * When no resolver is configured this returns the default verbatim. When
+	 * a resolver is configured its return value (an int) is returned in
+	 * place of the default — which the resolver may also pass through
+	 * unchanged for cases it does not want to override.
+	 *
+	 * Resolver-thrown exceptions are converted into an internal
+	 * {@see StatusResolverFailedException} for {@see self::handle_request()}
+	 * to handle, so a plugin bug never corrupts or duplicates a response.
+	 *
+	 * @param int              $default The framework-computed default status.
+	 * @param array            $output  The response body about to be sent (may include `errors`/`data`).
+	 * @param \WP_REST_Request $request The originating request.
+	 *
+	 * @throws StatusResolverFailedException When the resolver throws or returns a status code outside the 100..599 HTTP range.
+	 */
+	private function pick_status( int $default, array $output, \WP_REST_Request $request ): int {
+		if ( null === $this->status_resolver ) {
+			return $default;
+		}
+		// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal sentinel; never serialised to the wire.
+		try {
+			$resolved = $this->status_resolver->resolve_status( $default, $output, $request );
+		} catch ( \Throwable $e ) {
+			throw new StatusResolverFailedException( 'HTTP status resolver threw.', 0, $e );
+		}
+		// Guard against nonsensical return values. Range-checking outside the
+		// try/catch keeps this exception out of the generic-Throwable wrap
+		// above, so a bad return value surfaces as the same fixed-shape 500
+		// response as a throw — never as a malformed WP_REST_Response.
+		if ( $resolved < 100 || $resolved > 599 ) {
+			throw new StatusResolverFailedException(
+				sprintf( 'HTTP status resolver returned an out-of-range status code: %d.', $resolved )
+			);
+		}
+		// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+		return $resolved;
+	}
+
 	/**
 	 * Process the GraphQL request. Extracted so that handle_request() can
 	 * wrap everything in a single try/catch that respects debug mode.
@@ -236,21 +347,23 @@ abstract class GraphQLController {
 		// 3. Resolve query (cache lookup / APQ / parse).
 		$source = $this->query_cache->resolve( $query, $extensions );
 		if ( is_array( $source ) ) {
-			return new \WP_REST_Response( $source, $this->get_resolve_error_status( $source ) );
+			$default = $this->get_resolve_error_status( $source );
+			return new \WP_REST_Response( $source, $this->pick_status( $default, $source, $request ) );
 		}

 		// 4. Reject mutations over GET (GraphQL over HTTP spec).
 		if ( 'GET' === $request->get_method() && $this->document_has_mutation( $source, $operation_name ) ) {
-			return new \WP_REST_Response(
-				array(
-					'errors' => array(
-						array(
-							'message'    => 'Mutations are not allowed over GET requests. Use POST instead.',
-							'extensions' => array( 'code' => 'METHOD_NOT_ALLOWED' ),
-						),
+			$method_not_allowed_output = array(
+				'errors' => array(
+					array(
+						'message'    => 'Mutations are not allowed over GET requests. Use POST instead.',
+						'extensions' => array( 'code' => 'METHOD_NOT_ALLOWED' ),
 					),
 				),
-				405
+			);
+			return new \WP_REST_Response(
+				$method_not_allowed_output,
+				$this->pick_status( 405, $method_not_allowed_output, $request )
 			);
 		}

@@ -360,7 +473,8 @@ abstract class GraphQLController {
 		// error response on HTTP 200. Always derive the status from the
 		// errors array when one is present — clients that need "200 with
 		// partial data" semantics can still read the `errors` array.
-		$status = isset( $output['errors'] ) ? $this->get_error_status( $output['errors'] ) : 200;
+		$default = isset( $output['errors'] ) ? $this->get_error_status( $output['errors'] ) : 200;
+		$status  = $this->pick_status( $default, $output, $request );

 		return new \WP_REST_Response( $output, $status );
 	}
diff --git a/plugins/woocommerce/src/Internal/Api/StatusResolverFailedException.php b/plugins/woocommerce/src/Internal/Api/StatusResolverFailedException.php
new file mode 100644
index 00000000000..19c322d122c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/StatusResolverFailedException.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+/**
+ * Internal sentinel raised by {@see GraphQLController::pick_status()} when a
+ * plugin-supplied HTTP status resolver throws.
+ *
+ * The resolver is documented as "must not throw"; this exception lets the
+ * controller distinguish a resolver bug from any other Throwable so it can
+ * short-circuit to a fixed-shape 500 response without re-invoking the
+ * resolver. Never surfaced on the wire.
+ *
+ * @internal
+ */
+final class StatusResolverFailedException extends \RuntimeException {
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/Always200Resolver.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/Always200Resolver.php
new file mode 100644
index 00000000000..9a8e3e7667a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/Always200Resolver.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers;
+
+/**
+ * Test resolver that always returns 200 (Shopify-style override).
+ */
+class Always200Resolver {
+	/**
+	 * Always return 200 regardless of input.
+	 *
+	 * @param int              $default_status The framework-computed status.
+	 * @param array            $output         The response body about to be sent.
+	 * @param \WP_REST_Request $request        The originating request.
+	 */
+	public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int {
+		unset( $default_status, $output, $request );
+		return 200;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/AlwaysThrowingResolver.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/AlwaysThrowingResolver.php
new file mode 100644
index 00000000000..2e46b045cd5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/AlwaysThrowingResolver.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers;
+
+/**
+ * Test resolver that throws on every call. Exercises the throw-safety path:
+ * the framework must produce a clean 500 INTERNAL_ERROR without leaking the
+ * exception message and without re-invoking the resolver.
+ */
+class AlwaysThrowingResolver {
+	/**
+	 * The exception message used by every throw — kept distinctive so tests
+	 * can assert it does NOT appear on the wire.
+	 */
+	public const THROW_MESSAGE = 'resolver-implementation-detail';
+
+	/**
+	 * Always throw a RuntimeException.
+	 *
+	 * @param int              $default_status The framework-computed status.
+	 * @param array            $output         The response body about to be sent.
+	 * @param \WP_REST_Request $request        The originating request.
+	 *
+	 * @throws \RuntimeException Always.
+	 */
+	public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int {
+		unset( $default_status, $output, $request );
+		throw new \RuntimeException( self::THROW_MESSAGE );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/EagerCatchOnlyThrowingResolver.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/EagerCatchOnlyThrowingResolver.php
new file mode 100644
index 00000000000..187ea6e24d2
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/EagerCatchOnlyThrowingResolver.php
@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers;
+
+/**
+ * Test resolver that throws ONLY when handed the synthetic errors-shape
+ * built by GraphQLController::handle_request()'s eager-catch block.
+ * Exercises the no-loop guarantee: when the throw originates from inside
+ * that catch, the framework must not re-invoke the resolver while building
+ * the failure response.
+ */
+class EagerCatchOnlyThrowingResolver {
+	/**
+	 * Number of calls that returned successfully (i.e. did not match the
+	 * eager-catch heuristic).
+	 *
+	 * @var int
+	 */
+	public int $calls_succeeded = 0;
+
+	/**
+	 * Number of calls that matched the eager-catch heuristic and threw.
+	 *
+	 * @var int
+	 */
+	public int $calls_thrown = 0;
+
+	/**
+	 * Throw only on the synthetic eager-catch shape; pass through otherwise.
+	 *
+	 * Heuristic: a single error with no `data` key is exactly what
+	 * handle_request()'s catch block builds. Other decision points either
+	 * include `data` (decision #4) or are not currently exercised by this
+	 * fixture.
+	 *
+	 * @param int              $default_status The framework-computed status.
+	 * @param array            $output         The response body about to be sent.
+	 * @param \WP_REST_Request $request        The originating request.
+	 *
+	 * @throws \RuntimeException When the input matches the eager-catch shape.
+	 */
+	public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int {
+		unset( $request );
+		$has_one_error = isset( $output['errors'] ) && 1 === count( $output['errors'] );
+		$has_no_data   = ! array_key_exists( 'data', $output );
+		if ( $has_one_error && $has_no_data ) {
+			++$this->calls_thrown;
+			throw new \RuntimeException( 'eager-catch-only' );
+		}
+		++$this->calls_succeeded;
+		return $default_status;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/FixedReturnResolver.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/FixedReturnResolver.php
new file mode 100644
index 00000000000..62a8ddee6cf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/FixedReturnResolver.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers;
+
+/**
+ * Test resolver that returns a fixed integer regardless of inputs. Used to
+ * exercise pick_status()' range guard with values outside the 100..599 HTTP
+ * range.
+ */
+class FixedReturnResolver {
+	/**
+	 * The integer this resolver always returns.
+	 *
+	 * @var int
+	 */
+	private int $value;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param int $value The integer to return from every resolve_status() call.
+	 */
+	public function __construct( int $value ) {
+		$this->value = $value;
+	}
+
+	/**
+	 * Always return the configured value.
+	 *
+	 * @param int              $default_status The framework-computed status.
+	 * @param array            $output         The response body about to be sent.
+	 * @param \WP_REST_Request $request        The originating request.
+	 */
+	public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int {
+		unset( $default_status, $output, $request );
+		return $this->value;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/PassThroughResolver.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/PassThroughResolver.php
new file mode 100644
index 00000000000..b15c1ec3a9d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/PassThroughResolver.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers;
+
+/**
+ * Test resolver that returns the framework default verbatim, recording each
+ * call for later inspection.
+ */
+class PassThroughResolver {
+	/**
+	 * One entry per resolve_status() invocation, keyed `default` (the
+	 * framework-computed status passed in) and `codes` (the list of GraphQL
+	 * error codes seen in the output).
+	 *
+	 * @var array<int, array{default: int, codes: array<int, ?string>}>
+	 */
+	public array $calls = array();
+
+	/**
+	 * Pass through the framework default unchanged.
+	 *
+	 * @param int              $default_status The framework-computed status.
+	 * @param array            $output         The response body about to be sent.
+	 * @param \WP_REST_Request $request        The originating request.
+	 */
+	public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int {
+		unset( $request );
+		$this->calls[] = array(
+			'default' => $default_status,
+			'codes'   => array_map(
+				static fn ( $err ) => $err['extensions']['code'] ?? null,
+				$output['errors'] ?? array()
+			),
+		);
+		return $default_status;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/RemapInternalErrorResolver.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/RemapInternalErrorResolver.php
new file mode 100644
index 00000000000..a1e873838ed
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/RemapInternalErrorResolver.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers;
+
+/**
+ * Test resolver that remaps INTERNAL_ERROR responses to HTTP 503 and leaves
+ * everything else on the framework default. Exercises partial-override
+ * semantics.
+ */
+class RemapInternalErrorResolver {
+	/**
+	 * Remap INTERNAL_ERROR responses to 503; pass through everything else.
+	 *
+	 * @param int              $default_status The framework-computed status.
+	 * @param array            $output         The response body about to be sent.
+	 * @param \WP_REST_Request $request        The originating request.
+	 */
+	public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int {
+		unset( $request );
+		foreach ( $output['errors'] ?? array() as $error ) {
+			if ( 'INTERNAL_ERROR' === ( $error['extensions']['code'] ?? null ) ) {
+				return 503;
+			}
+		}
+		return $default_status;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerStatusResolverTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerStatusResolverTest.php
new file mode 100644
index 00000000000..8e5729810f1
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerStatusResolverTest.php
@@ -0,0 +1,486 @@
+<?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\Infrastructure\ClassResolver 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 Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers\Always200Resolver;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers\AlwaysThrowingResolver;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers\EagerCatchOnlyThrowingResolver;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers\FixedReturnResolver;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers\PassThroughResolver;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers\RemapInternalErrorResolver;
+use WC_REST_Unit_Test_Case;
+
+/**
+ * End-to-end tests covering the optional plugin-supplied HTTP status
+ * resolver: per-decision-point routing, partial overrides, the Shopify-style
+ * "always 200" override, and throw-safety.
+ *
+ * Test resolvers are wired into the controller via an anonymous subclass of
+ * the dummy autogenerated controller that overrides
+ * {@see GraphQLController::get_status_resolver()}. That mirrors the path
+ * ApiBuilder takes when emitting the autogenerated subclass for a plugin
+ * that ships an `Infrastructure\HttpStatusResolver`.
+ */
+class GraphQLControllerStatusResolverTest extends WC_REST_Unit_Test_Case {
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		wp_cache_flush();
+		DummyStore::reset();
+		DummyContainer::reset();
+		wp_set_current_user( 0 );
+	}
+
+	/**
+	 * Tear down.
+	 */
+	public function tearDown(): void {
+		DummyStore::reset();
+		DummyContainer::reset();
+		wp_set_current_user( 0 );
+		wp_cache_flush();
+		parent::tearDown();
+	}
+
+	/**
+	 * Build a controller wired to the dummy fixture API and the supplied
+	 * status resolver.
+	 *
+	 * @param ?object $resolver Status resolver to inject (or null for none).
+	 */
+	private function controller_with_resolver( ?object $resolver ): GraphQLController {
+		$controller = new class( $resolver ) extends DummyGraphQLController {
+			/**
+			 * The status resolver injected for this test, or null.
+			 *
+			 * @var ?object
+			 */
+			private ?object $injected_resolver;
+
+			/**
+			 * Constructor.
+			 *
+			 * @param ?object $injected_resolver The resolver to expose via get_status_resolver().
+			 */
+			public function __construct( ?object $injected_resolver ) {
+				$this->injected_resolver = $injected_resolver;
+			}
+
+			/**
+			 * Return the test-injected resolver instead of the (absent) plugin default.
+			 */
+			protected function get_status_resolver(): ?object {
+				return $this->injected_resolver;
+			}
+		};
+		$controller->init( new QueryCache() );
+		return $controller;
+	}
+
+	/**
+	 * 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;
+	}
+
+	// ------------------------------------------------------------------
+	// No-op / pass-through resolver: defaults must be unchanged.
+	// ------------------------------------------------------------------
+
+	/**
+	 * @testdox A pass-through resolver leaves the framework defaults unchanged across every decision point.
+	 */
+	public function test_pass_through_resolver_preserves_defaults(): void {
+		$resolver = new PassThroughResolver();
+		$sut      = $this->controller_with_resolver( $resolver );
+
+		// Decision point #4: success path.
+		$response = $sut->handle_request( $this->post_request( array( 'query' => '{ greeting { result } }' ) ) );
+		$this->assertSame( 200, $response->get_status() );
+
+		// Decision point #4: UNAUTHORIZED path (widget query without caps).
+		$response = $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 );
+
+		// Decision point #2: query cache resolve error (missing query).
+		$response = $sut->handle_request( $this->post_request( array() ) );
+		$this->assertSame( 400, $response->get_status() );
+
+		// Decision point #2: APQ hash miss → 200 PERSISTED_QUERY_NOT_FOUND.
+		$response = $sut->handle_request(
+			$this->post_request(
+				array(
+					'extensions' => array(
+						'persistedQuery' => array(
+							'version'    => 1,
+							'sha256Hash' => str_repeat( '0', 64 ),
+						),
+					),
+				)
+			)
+		);
+		$this->assertSame( 200, $response->get_status() );
+
+		// Decision point #3: mutation over GET → 405.
+		$response = $sut->handle_request(
+			$this->get_request( array( 'query' => 'mutation { increment(value: 1) { result } }' ) )
+		);
+		$this->assertSame( 405, $response->get_status() );
+
+		// Decision point #4 (final return): unhandled RuntimeException is
+		// caught inside Utils::translate_exceptions and surfaces as
+		// INTERNAL_ERROR → 500.
+		$response = $sut->handle_request(
+			$this->post_request( array( 'query' => '{ failing(kind: "runtime") { result } }' ) )
+		);
+		$this->assertSame( 500, $response->get_status() );
+
+		// Decision point #1 (eager-catch): an exception that escapes
+		// process_request entirely. Malformed variables JSON throws
+		// \InvalidArgumentException out of decode_json_param, which lands
+		// in handle_request's catch and formats as INVALID_ARGUMENT → 400.
+		$response = $sut->handle_request(
+			$this->get_request(
+				array(
+					'query'     => '{ greeting { result } }',
+					'variables' => 'not-json',
+				)
+			)
+		);
+		$this->assertSame( 400, $response->get_status() );
+
+		// Resolver was consulted at every decision point.
+		$this->assertGreaterThanOrEqual( 7, count( $resolver->calls ) );
+		// And every call received the framework's pre-resolver default as the first argument.
+		foreach ( $resolver->calls as $call ) {
+			$this->assertIsInt( $call['default'] );
+		}
+	}
+
+	// ------------------------------------------------------------------
+	// Shopify-style: always 200.
+	// ------------------------------------------------------------------
+
+	/**
+	 * @testdox A "always 200" resolver overrides every status across every decision point while leaving the body untouched.
+	 *
+	 * @dataProvider provider_always_200_cases
+	 *
+	 * @param string  $method                          HTTP method to use.
+	 * @param array   $payload                         Request payload.
+	 * @param ?string $expected_error_code             Expected error code on the wire (null = no errors).
+	 * @param int     $default_status_without_resolver Status the framework would have returned without a resolver.
+	 */
+	public function test_always_200_resolver_overrides_every_decision_point(
+		string $method,
+		array $payload,
+		?string $expected_error_code,
+		int $default_status_without_resolver
+	): void {
+		$sut = $this->controller_with_resolver( new Always200Resolver() );
+
+		$request  = 'POST' === $method ? $this->post_request( $payload ) : $this->get_request( $payload );
+		$response = $sut->handle_request( $request );
+
+		$this->assertSame( 200, $response->get_status() );
+
+		if ( null === $expected_error_code ) {
+			$this->assertEmpty( $response->get_data()['errors'] ?? array() );
+		} else {
+			$this->assertSame( $expected_error_code, $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+		}
+
+		// Sanity-check: without the resolver, the default would be different
+		// (skipping the cases where the default is already 200).
+		if ( 200 !== $default_status_without_resolver ) {
+			$baseline          = $this->controller_with_resolver( null );
+			$baseline_response = $baseline->handle_request(
+				'POST' === $method ? $this->post_request( $payload ) : $this->get_request( $payload )
+			);
+			$this->assertSame( $default_status_without_resolver, $baseline_response->get_status() );
+		}
+	}
+
+	/**
+	 * Data provider for {@see self::test_always_200_resolver_overrides_every_decision_point()}.
+	 *
+	 * @return array<string, array{0: string, 1: array, 2: ?string, 3: int}>
+	 */
+	public function provider_always_200_cases(): array {
+		return array(
+			'success (decision #4, no errors)'          => array( 'POST', array( 'query' => '{ greeting { result } }' ), null, 200 ),
+			'unauthorized (decision #4)'                => array( 'POST', array( 'query' => '{ widget(id: 1) { id } }' ), 'UNAUTHORIZED', 401 ),
+			'parse error (decision #2)'                 => array( 'POST', array( 'query' => '{ widget(id:' ), 'GRAPHQL_PARSE_ERROR', 400 ),
+			'missing query (decision #2)'               => array( 'POST', array(), 'BAD_REQUEST', 400 ),
+			'mutation over GET (decision #3)'           => array(
+				'GET',
+				array( 'query' => 'mutation { increment(value: 1) { result } }' ),
+				'METHOD_NOT_ALLOWED',
+				405,
+			),
+			'unhandled runtime exception (decision #4)' => array(
+				'POST',
+				array( 'query' => '{ failing(kind: "runtime") { result } }' ),
+				'INTERNAL_ERROR',
+				500,
+			),
+			'malformed variables JSON (decision #1, eager-catch)' => array(
+				'GET',
+				array(
+					'query'     => '{ greeting { result } }',
+					'variables' => 'not-json',
+				),
+				'INVALID_ARGUMENT',
+				400,
+			),
+		);
+	}
+
+	// ------------------------------------------------------------------
+	// Partial override: remap one code, defer otherwise.
+	// ------------------------------------------------------------------
+
+	/**
+	 * @testdox A resolver that remaps INTERNAL_ERROR to 503 only changes that response and leaves everything else on the default.
+	 */
+	public function test_partial_override_for_one_code(): void {
+		$sut = $this->controller_with_resolver( new RemapInternalErrorResolver() );
+
+		// Internal error → remapped to 503.
+		$response = $sut->handle_request(
+			$this->post_request( array( 'query' => '{ failing(kind: "runtime") { result } }' ) )
+		);
+		$this->assertSame( 503, $response->get_status() );
+		$this->assertSame( 'INTERNAL_ERROR', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+
+		// Unauthorized → unchanged at 401.
+		$response = $sut->handle_request( $this->post_request( array( 'query' => '{ widget(id: 1) { id } }' ) ) );
+		$this->assertSame( 401, $response->get_status() );
+
+		// Parse error → unchanged at 400.
+		$response = $sut->handle_request( $this->post_request( array( 'query' => '{ widget(id:' ) ) );
+		$this->assertSame( 400, $response->get_status() );
+
+		// Success → unchanged at 200.
+		$response = $sut->handle_request( $this->post_request( array( 'query' => '{ greeting { result } }' ) ) );
+		$this->assertSame( 200, $response->get_status() );
+	}
+
+	// ------------------------------------------------------------------
+	// Throw-safety: any throw produces a clean 500 INTERNAL_ERROR.
+	// ------------------------------------------------------------------
+
+	/**
+	 * @testdox A resolver that throws on every call always produces a 500 INTERNAL_ERROR with the canonical body shape.
+	 *
+	 * @dataProvider provider_throw_safety_inputs
+	 *
+	 * @param string $method  HTTP method to use.
+	 * @param array  $payload Request payload.
+	 */
+	public function test_throwing_resolver_produces_clean_500( string $method, array $payload ): void {
+		$sut = $this->controller_with_resolver( new AlwaysThrowingResolver() );
+
+		$request  = 'POST' === $method ? $this->post_request( $payload ) : $this->get_request( $payload );
+		$response = $sut->handle_request( $request );
+
+		$this->assertSame( 500, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertSame( 'INTERNAL_ERROR', $data['errors'][0]['extensions']['code'] ?? null );
+		$this->assertSame( 'An unexpected error occurred.', $data['errors'][0]['message'] ?? null );
+
+		// Resolver's exception message must NOT leak onto the wire.
+		$wire = wp_json_encode( $data );
+		$this->assertIsString( $wire );
+		$this->assertStringNotContainsString( AlwaysThrowingResolver::THROW_MESSAGE, $wire );
+	}
+
+	/**
+	 * Data provider for {@see self::test_throwing_resolver_produces_clean_500()}.
+	 * One input per status-decision point in the controller.
+	 *
+	 * @return array<string, array{0: string, 1: array}>
+	 */
+	public function provider_throw_safety_inputs(): array {
+		return array(
+			'#1 eager-catch (malformed variables JSON throws out of process_request)' => array(
+				'GET',
+				array(
+					'query'     => '{ greeting { result } }',
+					'variables' => 'not-json',
+				),
+			),
+			'#2 query-cache resolve error (missing query)' => array( 'POST', array() ),
+			'#3 mutation over GET'                         => array(
+				'GET',
+				array( 'query' => 'mutation { increment(value: 1) { result } }' ),
+			),
+			'#4 success path'                              => array(
+				'POST',
+				array( 'query' => '{ greeting { result } }' ),
+			),
+			'#4 unauthorized path'                         => array(
+				'POST',
+				array( 'query' => '{ widget(id: 1) { id } }' ),
+			),
+		);
+	}
+
+	/**
+	 * @testdox A resolver that throws ONLY when handed the eager-catch synthetic errors shape still terminates cleanly with a 500.
+	 *
+	 * Defensive: this is the configuration where the throw originates from
+	 * inside handle_request()'s own catch block. The framework must not loop
+	 * back into the resolver while building the failure response.
+	 */
+	public function test_resolver_throwing_only_on_eager_catch_does_not_loop(): void {
+		$resolver = new EagerCatchOnlyThrowingResolver();
+		$sut      = $this->controller_with_resolver( $resolver );
+
+		// Trigger an exception that escapes process_request (malformed
+		// variables JSON). handle_request()'s catch builds the synthetic
+		// errors shape and calls pick_status — which throws via this
+		// resolver's heuristic.
+		$response = $sut->handle_request(
+			$this->get_request(
+				array(
+					'query'     => '{ greeting { result } }',
+					'variables' => 'not-json',
+				)
+			)
+		);
+
+		$this->assertSame( 500, $response->get_status() );
+		$this->assertSame( 'INTERNAL_ERROR', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+
+		// The resolver was consulted exactly once (no infinite loop, no
+		// post-failure re-entry).
+		$this->assertSame( 1, $resolver->calls_thrown );
+		$this->assertSame( 0, $resolver->calls_succeeded );
+	}
+
+	// ------------------------------------------------------------------
+	// Out-of-range return: same clean 500 INTERNAL_ERROR as a throw.
+	// ------------------------------------------------------------------
+
+	/**
+	 * @testdox A resolver that returns an out-of-range int produces a clean 500 INTERNAL_ERROR.
+	 *
+	 * @dataProvider provider_out_of_range_values
+	 *
+	 * @param int $value Out-of-range integer to return from the resolver.
+	 */
+	public function test_out_of_range_resolver_return_produces_clean_500( int $value ): void {
+		$sut      = $this->controller_with_resolver( new FixedReturnResolver( $value ) );
+		$response = $sut->handle_request( $this->post_request( array( 'query' => '{ greeting { result } }' ) ) );
+
+		$this->assertSame( 500, $response->get_status() );
+		$this->assertSame( 'INTERNAL_ERROR', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+		$this->assertSame( 'An unexpected error occurred.', $response->get_data()['errors'][0]['message'] ?? null );
+	}
+
+	/**
+	 * Data provider for {@see self::test_out_of_range_resolver_return_produces_clean_500()}.
+	 * Covers the four "obviously invalid" buckets: zero, negative, sub-100, and above-599.
+	 *
+	 * @return array<string, array{0: int}>
+	 */
+	public function provider_out_of_range_values(): array {
+		return array(
+			'zero'                 => array( 0 ),
+			'negative'             => array( -1 ),
+			'just below 100'       => array( 99 ),
+			'just above 599'       => array( 600 ),
+			'huge positive number' => array( 99999 ),
+		);
+	}
+
+	/**
+	 * @testdox A resolver that returns an in-range int (100..599) is honoured even for non-IANA codes.
+	 *
+	 * The controller's range guard is intentionally permissive — it only
+	 * rejects nonsensical values, not arbitrary "unknown" codes. WordPress's
+	 * own `status_header()` will silently downgrade non-IANA codes to 200 at
+	 * the transport layer, but that is outside the resolver pipeline; this
+	 * test asserts the framework hands the resolved value through unchanged.
+	 */
+	public function test_in_range_but_non_iana_value_is_honoured_by_pick_status(): void {
+		$sut      = $this->controller_with_resolver( new FixedReturnResolver( 222 ) );
+		$response = $sut->handle_request( $this->post_request( array( 'query' => '{ greeting { result } }' ) ) );
+
+		// Note: WP_REST_Response::get_status() returns whatever the controller
+		// stored, before WP's status_header() lookup-table filter — so 222
+		// shows up here even though a real HTTP client would observe 200.
+		$this->assertSame( 222, $response->get_status() );
+	}
+
+	// ------------------------------------------------------------------
+	// No resolver: behaviour is unchanged from before this PR.
+	// ------------------------------------------------------------------
+
+	/**
+	 * @testdox A controller without a resolver behaves exactly as the framework defaults dictate.
+	 */
+	public function test_no_resolver_preserves_defaults(): void {
+		$sut = $this->controller_with_resolver( null );
+
+		$this->assertSame(
+			200,
+			$sut->handle_request( $this->post_request( array( 'query' => '{ greeting { result } }' ) ) )->get_status()
+		);
+		$this->assertSame(
+			401,
+			$sut->handle_request( $this->post_request( array( 'query' => '{ widget(id: 1) { id } }' ) ) )->get_status()
+		);
+		$this->assertSame(
+			405,
+			$sut->handle_request( $this->get_request( array( 'query' => 'mutation { increment(value: 1) { result } }' ) ) )->get_status()
+		);
+		$this->assertSame(
+			500,
+			$sut->handle_request( $this->post_request( array( 'query' => '{ failing(kind: "runtime") { result } }' ) ) )->get_status()
+		);
+		$this->assertSame(
+			400,
+			$sut->handle_request(
+				$this->get_request(
+					array(
+						'query'     => '{ greeting { result } }',
+						'variables' => 'not-json',
+					)
+				)
+			)->get_status()
+		);
+	}
+}