Commit 35b6740513 for woocommerce

commit 35b674051331f06a5b2f1ddb7c35d4eda48ecfba
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Tue Nov 18 13:42:07 2025 +0100

    Add a basic RestApiCache trait (#61798)

diff --git a/plugins/woocommerce/changelog/pr-61798 b/plugins/woocommerce/changelog/pr-61798
new file mode 100644
index 0000000000..8bc78ce463
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-61798
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a basic RestApiCache trait
diff --git a/plugins/woocommerce/src/Internal/Traits/RestApiCache.php b/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
new file mode 100644
index 0000000000..c9cb496da4
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
@@ -0,0 +1,406 @@
+<?php
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Traits;
+
+use Automattic\WooCommerce\Internal\Caches\VersionStringGenerator;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+use WP_REST_Request;
+use WP_REST_Response;
+
+/**
+ * This trait provides caching capabilities for REST API endpoints using the WordPress cache.
+ *
+ * - The output of all the REST API endpoints whose callback declaration is wrapped
+ *   in a call to 'with_cache' will be cached using wp_cache_* functions.
+ * - For the purposes of caching, a request is uniquely identified by its route,
+ *   HTTP method, query string, and user ID.
+ * - The VersionStringGenerator class is used to track versions of entities included
+ *   in the responses (an "entity" is any object that is uniquely identified by type and id
+ *   and contributes with information to be included in the response),
+ *   so that when those entities change, the relevant cached responses become invalid.
+ *   Modification of entity versions must be done externally by the code that modifies
+ *   those entities (via calls to VersionStringGenerator::generate_version).
+ * - Caching can be disabled for a given request by adding a '_skip_cache=true|1'
+ *   to the query string.
+ * - A X-WC-Cache HTTP header is added to responses to indicate cache status:
+ *   HIT, MISS, or SKIP.
+ * - Cached response TTL is fixed to one hour.
+ *
+ * Usage: Wrap endpoint callbacks with the `with_cache()` method when registering routes.
+ *
+ * Example:
+ *
+ * class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
+ *     use RestApiCache;  // REQUIRED
+ *
+ *     public function __construct() {
+ *         parent::__construct();
+ *         $this->initialize_rest_api_cache();  // REQUIRED
+ *     }
+ *
+ *     protected function get_default_entity_type(): ?string {
+ *         return 'product';  // REQUIRED (or specify entity_type in each with_cache call)
+ *     }
+ *
+ *     public function register_routes() {
+ *         register_rest_route(
+ *             $this->namespace,
+ *             '/' . $this->rest_base . '/(?P<id>[\d]+)',
+ *             array(
+ *                 'methods'  => WP_REST_Server::READABLE,
+ *                 'callback' => $this->with_cache(
+ *                     array( $this, 'get_item' ),
+ *                     array(
+ *                         // String, optional if get_default_entity_type() is overridden.
+ *                         'entity_type' => 'product',
+ *                     )
+ *                 ),
+ *             )
+ *         );
+ *     }
+ * }
+ *
+ * Cache invalidation happens when:
+ * - Entity versions change (tracked via VersionStringGenerator).
+ * - Cached response TTL expires.
+ *
+ * NOTE: This caching mechanism uses the WordPress cache (wp_cache_* functions).
+ * By default caching is only enabled when an external object cache is enabled
+ * (checked via call to VersionStringGenerator::can_use()), so the cache is persistent
+ * across requests and not just for the current request.
+ *
+ * @since   10.4.0
+ */
+trait RestApiCache {
+	/**
+	 * Cache group name for REST API responses.
+	 *
+	 * @var string
+	 */
+	private static string $cache_group = 'woocommerce_rest_api_cache';
+
+	/**
+	 * Cache TTL in seconds.
+	 *
+	 * @var int
+	 */
+	private static int $cache_ttl = HOUR_IN_SECONDS;
+
+	/**
+	 * The instance of VersionStringGenerator to use, or null if caching is disabled.
+	 *
+	 * @var VersionStringGenerator|null
+	 */
+	private ?VersionStringGenerator $version_string_generator = null;
+
+	/**
+	 * Initialize the trait.
+	 * This MUST be called from the controller's constructor.
+	 */
+	protected function initialize_rest_api_cache(): void {
+		$generator                      = wc_get_container()->get( VersionStringGenerator::class );
+		$this->version_string_generator = $generator->can_use() ? $generator : null;
+	}
+
+	/**
+	 * Wrap an endpoint callback declaration with caching logic.
+	 * Usage: `'callback' => $this->with_cache( array( $this, 'endpoint_callback_method' ) )`
+	 *        `'callback' => $this->with_cache( array( $this, 'endpoint_callback_method' ), [ 'entity_type' => 'product' ] )`
+	 *
+	 * @param callable $callback The original endpoint callback.
+	 * @param array    $config   Caching configuration:
+	 *                           - entity_type: string (falls back to get_default_entity_type()).
+	 * @return callable Wrapped callback.
+	 */
+	protected function with_cache( callable $callback, array $config = array() ): callable {
+		return fn( $request ) => $this->handle_cacheable_request( $request, $callback, $config );
+	}
+
+	/**
+	 * Handle a request with caching logic.
+	 *
+	 * Strategy: Try to use cached response if available and valid, otherwise execute the endpoint
+	 * callback and cache the response (if successful) for future requests.
+	 *
+	 * @param WP_REST_Request $request  The request object.
+	 * @param callable        $callback The original endpoint callback.
+	 * @param array           $config   Caching configuration specified for the endpoint.
+	 * @return WP_REST_Response|\WP_Error The response.
+	 */
+	private function handle_cacheable_request( WP_REST_Request $request, callable $callback, array $config ) {
+		if ( is_null( $this->version_string_generator ) ) {
+			return call_user_func( $callback, $request );
+		}
+
+		$should_skip_cache = ! $this->should_use_cache_for_request( $request );
+		if ( ! $should_skip_cache ) {
+			$cached_config     = $this->build_cache_config( $request, $config );
+			$should_skip_cache = is_null( $cached_config );
+		}
+
+		if ( $should_skip_cache ) {
+			$response = call_user_func( $callback, $request );
+			if ( ! is_wp_error( $response ) ) {
+				$response = rest_ensure_response( $response );
+				$response->header( 'X-WC-Cache', 'SKIP' );
+			}
+			return $response;
+		}
+
+		$cached_response = $this->get_cached_response( $cached_config );
+
+		if ( $cached_response ) {
+			$cached_response->header( 'X-WC-Cache', 'HIT' );
+			return $cached_response;
+		}
+
+		$authoritative_response = call_user_func( $callback, $request );
+
+		return $this->maybe_cache_response( $request, $authoritative_response, $cached_config );
+	}
+
+	/**
+	 * Check if caching should be used for a particular incoming request.
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 * @return bool True if caching should be used, false otherwise.
+	 */
+	private function should_use_cache_for_request( WP_REST_Request $request ): bool {
+		$skip_cache   = $request->get_param( '_skip_cache' );
+		$should_cache = ! ( 'true' === $skip_cache || '1' === $skip_cache );
+
+		/**
+		 * Filter whether to enable response caching for a given REST API controller.
+		 *
+		 * @since 10.4.0
+		 *
+		 * @param bool            $enable_caching Whether to enable response caching (result of !_skip_cache evaluation).
+		 * @param object          $controller     The controller instance.
+		 * @param WP_REST_Request $request        The request object.
+		 * @return bool True to enable response caching, false to disable.
+		 */
+		return apply_filters(
+			'woocommerce_rest_api_enable_response_caching',
+			$should_cache,
+			$this,
+			$request
+		);
+	}
+
+	/**
+	 * Build the output cache entry configuration from the request and per-endpoint config.
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 * @param array           $config  Raw configuration array passed to with_cache.
+	 * @return array|null Normalized cache config with keys: entity_type, cache_key. Returns null if entity type is not available.
+	 */
+	private function build_cache_config( WP_REST_Request $request, array $config ): ?array {
+		$entity_type = $config['entity_type'] ?? $this->get_default_entity_type();
+
+		if ( ! $entity_type ) {
+			wc_get_container()->get( LegacyProxy::class )->call_function(
+				'wc_doing_it_wrong',
+				__METHOD__,
+				'No entity type provided and no default entity type available. Skipping cache.',
+				'10.4.0'
+			);
+			return null;
+		}
+
+		return array(
+			'entity_type' => $entity_type,
+			'cache_key'   => $this->get_cache_key( $request, $entity_type ),
+		);
+	}
+
+	/**
+	 * Cache the response if it's successful and return it with appropriate headers.
+	 *
+	 * Only caches responses with 2xx status codes. Always adds the X-WC-Cache header
+	 * with value MISS if the response was cached, or SKIP if it was not cached.
+	 *
+	 * Supports both WP_REST_Response objects and raw data (which will be wrapped in a response object).
+	 * Error objects are returned as-is without caching.
+	 *
+	 * @param WP_REST_Request                         $request       The request object.
+	 * @param WP_REST_Response|\WP_Error|array|object $response      The response to potentially cache.
+	 * @param array                                   $cached_config Caching configuration from build_cache_config().
+	 * @return WP_REST_Response|\WP_Error The response with appropriate cache headers.
+	 */
+	private function maybe_cache_response( WP_REST_Request $request, $response, array $cached_config ) {
+		if ( is_wp_error( $response ) ) {
+			return $response;
+		}
+
+		$response = rest_ensure_response( $response );
+
+		$cached = false;
+
+		$status = $response->get_status();
+		if ( $status >= 200 && $status <= 299 ) {
+			$data       = $response->get_data();
+			$entity_ids = is_array( $data ) ? $this->extract_entity_ids( $data ) : array();
+
+			$this->store_cached_response(
+				$cached_config['cache_key'],
+				$data,
+				$status,
+				$cached_config['entity_type'],
+				$entity_ids
+			);
+
+			$cached = true;
+		}
+
+		$response->header( 'X-WC-Cache', $cached ? 'MISS' : 'SKIP' );
+		return $response;
+	}
+
+	/**
+	 * Get the default type for entities included in responses.
+	 *
+	 * This can be customized per-endpoint via the config array
+	 * passed to with_cache() ('entity_type' key).
+	 *
+	 * @return string|null Entity type (e.g., 'product', 'order'), or null if no controller-wide default.
+	 */
+	protected function get_default_entity_type(): ?string {
+		return null;
+	}
+
+	/**
+	 * Extract entity IDs from response data.
+	 *
+	 * This implementation assumes the response is either:
+	 * - An array with an 'id' field (single item)
+	 * - An array of arrays each having an 'id' field (collection)
+	 *
+	 * @param array $data Response data.
+	 * @return array Array of entity IDs.
+	 */
+	private function extract_entity_ids( array $data ): array {
+		$ids = array();
+
+		if ( isset( $data[0] ) && is_array( $data[0] ) ) {
+			foreach ( $data as $item ) {
+				if ( isset( $item['id'] ) ) {
+					$ids[] = $item['id'];
+				}
+			}
+		} elseif ( isset( $data['id'] ) ) {
+			$ids[] = $data['id'];
+		}
+
+		// Filter out null/false values but keep 0 and empty strings as they could be valid IDs.
+		return array_unique(
+			array_filter( $ids, fn ( $id ) => ! is_null( $id ) && false !== $id )
+		);
+	}
+
+	/**
+	 * Get cache key information that uniquely identifies a request.
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 * @return array Array of cache key information parts.
+	 */
+	private function get_cache_key_info( WP_REST_Request $request ): array {
+		$request_query_params = $request->get_query_params();
+		if ( is_array( $request_query_params ) ) {
+			ksort( $request_query_params );
+		}
+
+		$user_id = wc_get_container()->get( LegacyProxy::class )->call_function( 'get_current_user_id' );
+
+		return array(
+			$request->get_route(),
+			$request->get_method(),
+			wp_json_encode( $request_query_params ),
+			"user_{$user_id}",
+		);
+	}
+
+	/**
+	 * Generate a cache key for a given request.
+	 *
+	 * @param WP_REST_Request $request     The request object.
+	 * @param string          $entity_type The entity type.
+	 * @return string Cache key.
+	 */
+	private function get_cache_key( WP_REST_Request $request, string $entity_type ): string {
+		$cache_key_parts = $this->get_cache_key_info( $request );
+		$request_hash    = md5( implode( '-', $cache_key_parts ) );
+		return "wc_rest_api_cache_{$entity_type}-{$request_hash}";
+	}
+
+	/**
+	 * Get a cached response, but only if it's valid (otherwise the cached response will be invalidated).
+	 *
+	 * @param array $cached_config Built caching configuration from build_cache_config().
+	 * @return WP_REST_Response|null Cached response, or null if not available or has been invalidated.
+	 */
+	private function get_cached_response( array $cached_config ): ?WP_REST_Response {
+		$cache_key   = $cached_config['cache_key'];
+		$entity_type = $cached_config['entity_type'];
+
+		$found  = false;
+		$cached = wp_cache_get( $cache_key, self::$cache_group, false, $found );
+
+		if ( ! $found || ! array_key_exists( 'data', $cached ) || ! isset( $cached['entity_versions'], $cached['created_at'] ) ) {
+			return null;
+		}
+
+		$current_time    = wc_get_container()->get( LegacyProxy::class )->call_function( 'time' );
+		$expiration_time = $cached['created_at'] + self::$cache_ttl;
+		if ( $current_time >= $expiration_time ) {
+			wp_cache_delete( $cache_key, self::$cache_group );
+			return null;
+		}
+
+		foreach ( $cached['entity_versions'] as $entity_id => $cached_version ) {
+			$version_id      = "{$entity_type}_{$entity_id}";
+			$current_version = $this->version_string_generator->get_version( $version_id );
+			if ( $current_version !== $cached_version ) {
+				wp_cache_delete( $cache_key, self::$cache_group );
+				return null;
+			}
+		}
+
+		// At this point the cached response is valid.
+		$response = new WP_REST_Response( $cached['data'], $cached['status_code'] ?? 200 );
+
+		return $response;
+	}
+
+	/**
+	 * Store a response in cache.
+	 *
+	 * @param string $cache_key   The cache key.
+	 * @param mixed  $data        The response data to cache.
+	 * @param int    $status_code The HTTP status code of the response.
+	 * @param string $entity_type The entity type.
+	 * @param array  $entity_ids  Array of entity IDs in the response.
+	 */
+	private function store_cached_response( string $cache_key, $data, int $status_code, string $entity_type, array $entity_ids ): void {
+		$entity_versions = array();
+		foreach ( $entity_ids as $entity_id ) {
+			$version_id = "{$entity_type}_{$entity_id}";
+			$version    = $this->version_string_generator->get_version( $version_id );
+			if ( $version ) {
+				$entity_versions[ $entity_id ] = $version;
+			}
+		}
+
+		$cache_data = array(
+			'data'            => $data,
+			'entity_versions' => $entity_versions,
+			'created_at'      => wc_get_container()->get( LegacyProxy::class )->call_function( 'time' ),
+		);
+
+		if ( 200 !== $status_code ) {
+			$cache_data['status_code'] = $status_code;
+		}
+
+		wp_cache_set( $cache_key, $cache_data, self::$cache_group, self::$cache_ttl );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php b/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
new file mode 100644
index 0000000000..f7e26a266e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
@@ -0,0 +1,486 @@
+<?php
+/**
+ * RestApiCacheTest class file.
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Traits;
+
+use Automattic\WooCommerce\Internal\Caches\VersionStringGenerator;
+use Automattic\WooCommerce\Internal\Traits\RestApiCache;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+use WC_REST_Unit_Test_Case;
+use WP_REST_Controller;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+use WP_Error;
+
+/**
+ * Tests for the simplified RestApiCache trait.
+ */
+class RestApiCacheTest extends WC_REST_Unit_Test_Case {
+
+	private const CACHE_GROUP = 'woocommerce_rest_api_cache';
+
+	/**
+	 * System under test.
+	 *
+	 * @var object
+	 */
+	private $sut;
+
+	/**
+	 * @var VersionStringGenerator
+	 */
+	private VersionStringGenerator $version_generator;
+
+	/**
+	 * @var int
+	 */
+	private int $fixed_time = 1234567890;
+
+	/**
+	 * Set up before each test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+			array(
+				'time'                      => fn() => $this->fixed_time,
+				'get_current_user_id'       => fn() => 1,
+				'wp_using_ext_object_cache' => fn() => true,
+			)
+		);
+
+		// Needed to ensure VersionStringGenerator uses the mocked wp_using_ext_object_cache.
+		$this->reset_container_resolutions();
+
+		$this->version_generator = wc_get_container()->get( VersionStringGenerator::class );
+		$this->sut               = $this->create_test_controller();
+
+		global $wp_rest_server;
+		$wp_rest_server = new WP_REST_Server();
+		$this->server   = $wp_rest_server;
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( 'rest_api_init' );
+		$this->sut->register_routes();
+	}
+
+	/**
+	 * Tear down after each test.
+	 */
+	public function tearDown(): void {
+		global $wp_rest_server;
+		$wp_rest_server = null;
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox First request returns MISS and caches response, second request returns HIT with cached data.
+	 */
+	public function test_caching_workflow_miss_then_hit() {
+		$response1 = $this->query_endpoint( 'multiple_entities' );
+
+		$this->assertCacheHeader( $response1, 'MISS' );
+		$this->assertEquals( $this->sut->responses['multiple_entities'], $response1->get_data() );
+
+		$cache_keys = $this->get_all_cache_keys();
+		$this->assertCount( 1, $cache_keys );
+		$cache_key    = $cache_keys[0];
+		$cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+
+		$this->assertIsArray( $cached_entry );
+		$this->assertArrayHasKey( 'data', $cached_entry );
+		$this->assertArrayHasKey( 'entity_versions', $cached_entry );
+		$this->assertArrayHasKey( 'created_at', $cached_entry );
+		$this->assertEquals( $this->sut->responses['multiple_entities'], $cached_entry['data'] );
+		$this->assertEquals( $this->fixed_time, $cached_entry['created_at'] );
+		$this->assertCount( 2, $cached_entry['entity_versions'] );
+
+		$this->assertNotEmpty( $this->version_generator->get_version( 'product_2' ) );
+		$this->assertNotEmpty( $this->version_generator->get_version( 'product_3' ) );
+
+		$modified_data        = array(
+			array(
+				'id'   => 999,
+				'name' => 'Modified Product',
+			),
+		);
+		$cached_entry['data'] = $modified_data;
+		wp_cache_set( $cache_key, $cached_entry, self::CACHE_GROUP, HOUR_IN_SECONDS );
+
+		$response2 = $this->query_endpoint( 'multiple_entities' );
+		$this->assertCacheHeader( $response2, 'HIT' );
+		$this->assertEquals( $modified_data, $response2->get_data() );
+	}
+
+	/**
+	 * @testdox Expired cache entries are rejected and deleted.
+	 */
+	public function test_expired_cache_entries_are_rejected() {
+		$response1 = $this->query_endpoint( 'single_entity' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+		$this->assertCount( 1, $this->get_all_cache_keys() );
+
+		$response2 = $this->query_endpoint( 'single_entity' );
+		$this->assertCacheHeader( $response2, 'HIT' );
+
+		$this->fixed_time += HOUR_IN_SECONDS + 1;
+
+		$response3 = $this->query_endpoint( 'single_entity' );
+		$this->assertCacheHeader( $response3, 'MISS' );
+		$this->assertCount( 1, $this->get_all_cache_keys() );
+	}
+
+	/**
+	 * @testdox Cache is invalidated when entity versions change.
+	 * @testWith [2, true]
+	 *           [3, true]
+	 *           [999, false]
+	 *
+	 * @param int  $entity_id                   Entity ID to modify.
+	 * @param bool $cache_invalidation_expected Whether cache invalidation is expected.
+	 */
+	public function test_cache_invalidated_when_entity_version_changes( int $entity_id, bool $cache_invalidation_expected ) {
+		$response1 = $this->query_endpoint( 'multiple_entities' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$response2 = $this->query_endpoint( 'multiple_entities' );
+		$this->assertCacheHeader( $response2, 'HIT' );
+
+		$this->version_generator->generate_version( "product_{$entity_id}" );
+		$this->fixed_time += 1;
+
+		$response3 = $this->query_endpoint( 'multiple_entities' );
+		$this->assertCacheHeader( $response3, $cache_invalidation_expected ? 'MISS' : 'HIT' );
+	}
+
+	/**
+	 * @testdox Cache keys differ based on query string parameters.
+	 */
+	public function test_cache_key_depends_on_query_string() {
+		$response1 = $this->query_endpoint( 'single_entity' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+		$this->assertCount( 1, $this->get_all_cache_keys() );
+
+		$response2 = $this->query_endpoint( 'single_entity', array( 'foo' => 'bar' ) );
+		$this->assertCacheHeader( $response2, 'MISS' );
+		$this->assertCount( 2, $this->get_all_cache_keys() );
+
+		$response3 = $this->query_endpoint( 'single_entity' );
+		$this->assertCacheHeader( $response3, 'HIT' );
+	}
+
+	/**
+	 * @testdox Cache keys differ based on HTTP method.
+	 */
+	public function test_cache_key_depends_on_http_method() {
+		$response1 = $this->query_endpoint( 'multi_method', null, 'GET' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+		$this->assertCount( 1, $this->get_all_cache_keys() );
+
+		$response2 = $this->query_endpoint( 'multi_method', null, 'POST' );
+		$this->assertCacheHeader( $response2, 'MISS' );
+		$this->assertCount( 2, $this->get_all_cache_keys() );
+
+		$response3 = $this->query_endpoint( 'multi_method', null, 'GET' );
+		$this->assertCacheHeader( $response3, 'HIT' );
+
+		$response4 = $this->query_endpoint( 'multi_method', null, 'POST' );
+		$this->assertCacheHeader( $response4, 'HIT' );
+	}
+
+	/**
+	 * @testdox Caching is skipped when _skip_cache parameter is set.
+	 * @testWith ["1"]
+	 *           ["true"]
+	 *
+	 * @param string $skip_cache_value Value for _skip_cache parameter.
+	 */
+	public function test_skip_cache_parameter_bypasses_caching( $skip_cache_value ) {
+		$response = $this->query_endpoint( 'single_entity', array( '_skip_cache' => $skip_cache_value ) );
+		$this->assertCacheHeader( $response, 'SKIP' );
+		$this->assertCount( 0, $this->get_all_cache_keys() );
+	}
+
+	/**
+	 * @testdox Caching is skipped when entity versions cache is disabled.
+	 */
+	public function test_caching_skipped_when_entity_cache_disabled() {
+		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+			array( 'wp_using_ext_object_cache' => fn() => false )
+		);
+		$this->reset_container_resolutions();
+		$this->sut->reinitialize_cache();
+
+		$response = $this->query_endpoint( 'single_entity' );
+		$this->assertCacheHeader( $response, null );
+		$this->assertCount( 0, $this->get_all_cache_keys() );
+	}
+
+	/**
+	 * @testdox Caching is skipped when filter returns false.
+	 */
+	public function test_caching_skipped_when_filter_returns_false() {
+		add_filter( 'woocommerce_rest_api_enable_response_caching', '__return_false' );
+
+		$response = $this->query_endpoint( 'single_entity' );
+		$this->assertCacheHeader( $response, 'SKIP' );
+		$this->assertCount( 0, $this->get_all_cache_keys() );
+
+		remove_all_filters( 'woocommerce_rest_api_enable_response_caching' );
+	}
+
+	/**
+	 * @testdox Custom entity_type can be specified per endpoint.
+	 */
+	public function test_custom_entity_type_per_endpoint() {
+		$response1 = $this->query_endpoint( 'custom_entity_type' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+		$this->assertCount( 1, $this->get_all_cache_keys() );
+		$this->assertNotEmpty( $this->version_generator->get_version( 'custom_thing_100' ) );
+
+		$response2 = $this->query_endpoint( 'custom_entity_type' );
+		$this->assertCacheHeader( $response2, 'HIT' );
+
+		$this->version_generator->generate_version( 'custom_thing_100' );
+		$response3 = $this->query_endpoint( 'custom_entity_type' );
+		$this->assertCacheHeader( $response3, 'MISS' );
+	}
+
+	/**
+	 * @testdox Cache varies by user ID automatically.
+	 */
+	public function test_cache_varies_by_user() {
+		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+			array( 'get_current_user_id' => fn() => 1 )
+		);
+		$response1 = $this->query_endpoint( 'single_entity' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+		$this->assertCount( 1, $this->get_all_cache_keys() );
+
+		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+			array( 'get_current_user_id' => fn() => 2 )
+		);
+		$response2 = $this->query_endpoint( 'single_entity' );
+		$this->assertCacheHeader( $response2, 'MISS' );
+		$this->assertCount( 2, $this->get_all_cache_keys() );
+	}
+
+	/**
+	 * @testdox Non-array responses (scalars, null, 204 No Content) are cached without crashing.
+	 * @testWith ["scalar"]
+	 *           ["null"]
+	 *           ["no_content"]
+	 *
+	 * @param string $response_type Type of response to test.
+	 */
+	public function test_non_array_responses_are_handled_gracefully( string $response_type ) {
+		$response1 = $this->query_endpoint( 'non_array_response', array( 'type' => $response_type ) );
+		$this->assertCacheHeader( $response1, 'MISS' );
+		$this->assertCount( 1, $this->get_all_cache_keys() );
+
+		$response2 = $this->query_endpoint( 'non_array_response', array( 'type' => $response_type ) );
+		$this->assertCacheHeader( $response2, 'HIT' );
+		$this->assertEquals( $response1->get_data(), $response2->get_data() );
+	}
+
+	/**
+	 * Test that controllers can return raw arrays without wrapping in WP_REST_Response.
+	 */
+	public function test_raw_array_responses_are_cached() {
+		$response1 = $this->query_endpoint( 'raw_array_response' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+		$this->assertInstanceOf( WP_REST_Response::class, $response1 );
+		$this->assertSame( 200, $response1->get_status() );
+		$this->assertIsArray( $response1->get_data() );
+		$this->assertArrayHasKey( 'id', $response1->get_data() );
+		$this->assertSame( 42, $response1->get_data()['id'] );
+
+		$response2 = $this->query_endpoint( 'raw_array_response' );
+		$this->assertCacheHeader( $response2, 'HIT' );
+		$this->assertEquals( $response1->get_data(), $response2->get_data() );
+	}
+
+	/**
+	 * @testdox SKIP header is added to raw array responses when cache is skipped.
+	 */
+	public function test_skip_header_added_for_raw_array_responses() {
+		$response = $this->query_endpoint( 'raw_array_response', array( '_skip_cache' => 'true' ) );
+		$this->assertCacheHeader( $response, 'SKIP' );
+		$this->assertInstanceOf( WP_REST_Response::class, $response );
+		$this->assertIsArray( $response->get_data() );
+		$this->assertArrayHasKey( 'id', $response->get_data() );
+		$this->assertSame( 42, $response->get_data()['id'] );
+		$this->assertCount( 0, $this->get_all_cache_keys() );
+	}
+
+	/**
+	 * Query an endpoint and return the response.
+	 *
+	 * @param string      $endpoint_name Endpoint name.
+	 * @param array|null  $query_params  Optional query parameters.
+	 * @param string|null $method        Optional HTTP method (default: GET).
+	 */
+	private function query_endpoint( $endpoint_name, $query_params = null, $method = null ) {
+		$request = new WP_REST_Request( $method ?? 'GET', "/wc/v3/rest_api_cache_test/{$endpoint_name}" );
+		if ( ! is_null( $query_params ) ) {
+			$request->set_query_params( $query_params );
+		}
+		return $this->server->dispatch( $request );
+	}
+
+	/**
+	 * Assert cache header value (HIT, MISS, SKIP, or null for no header).
+	 *
+	 * @param WP_REST_Response $response       The response to check.
+	 * @param string|null      $expected_value Expected header value.
+	 */
+	private function assertCacheHeader( $response, ?string $expected_value ) {
+		$this->assertInstanceOf( WP_REST_Response::class, $response );
+		if ( is_null( $expected_value ) ) {
+			$this->assertArrayNotHasKey( 'X-WC-Cache', $response->get_headers() );
+		} else {
+			$this->assertEquals( $expected_value, $response->get_headers()['X-WC-Cache'] );
+		}
+	}
+
+	/**
+	 * Get all cache keys (works with unit test memory cache only).
+	 */
+	private function get_all_cache_keys(): array {
+		global $wp_object_cache;
+		return isset( $wp_object_cache->cache[ self::CACHE_GROUP ] )
+			? array_keys( $wp_object_cache->cache[ self::CACHE_GROUP ] )
+			: array();
+	}
+
+	/**
+	 * Create a test controller.
+	 */
+	private function create_test_controller() {
+		return new class() extends WP_REST_Controller {
+			// phpcs:disable Squiz.Commenting
+			use RestApiCache;
+
+			public $responses           = array(
+				'single_entity'      => array(
+					'id'   => 1,
+					'name' => 'Product 1',
+				),
+				'multiple_entities'  => array(
+					array(
+						'id'   => 2,
+						'name' => 'Product 2',
+					),
+					array(
+						'id'   => 3,
+						'name' => 'Product 3',
+					),
+				),
+				'custom_entity_type' => array(
+					'id'   => 100,
+					'name' => 'Custom Thing 100',
+				),
+			);
+			public $default_entity_type = 'product';
+
+			public function __construct() {
+				$this->namespace = 'wc/v3';
+				$this->rest_base = 'rest_api_cache_test';
+				$this->initialize_rest_api_cache();
+			}
+
+			public function register_routes() {
+				$this->register_cached_route( 'single_entity' );
+				$this->register_cached_route( 'multiple_entities' );
+				$this->register_cached_route( 'custom_entity_type', array( 'entity_type' => 'custom_thing' ) );
+				$this->register_cached_route( 'non_array_response', array( 'entity_type' => 'custom_thing' ), true );
+				$this->register_cached_route( 'raw_array_response', array(), false, true );
+				$this->register_multi_method_route();
+			}
+
+			private function register_cached_route( string $endpoint, array $cache_args = array(), bool $non_array_request = false, bool $raw_response = false ) {
+				register_rest_route(
+					$this->namespace,
+					'/' . $this->rest_base . '/' . $endpoint,
+					array(
+						'methods'             => 'GET',
+						'callback'            => $this->with_cache(
+							function ( $request ) use ( $endpoint, $non_array_request, $raw_response ) {
+								if ( $raw_response ) {
+									return $this->handle_raw_array_request( $endpoint, $request );
+								}
+								return $non_array_request ?
+									$this->handle_non_array_request( $request ) :
+									$this->handle_request( $endpoint, $request );
+							},
+							$cache_args
+						),
+						'permission_callback' => '__return_true',
+					)
+				);
+			}
+
+			private function register_multi_method_route() {
+				register_rest_route(
+					$this->namespace,
+					'/' . $this->rest_base . '/multi_method',
+					array(
+						'methods'             => array( 'GET', 'POST' ),
+						'callback'            => $this->with_cache(
+							function ( $request ) {
+								$method = $request->get_method();
+								return new WP_REST_Response(
+									array(
+										'id'     => 'GET' === $method ? 10 : 20,
+										'method' => $method,
+									),
+									200
+								);
+							}
+						),
+						'permission_callback' => '__return_true',
+					)
+				);
+			}
+
+			protected function get_default_entity_type(): ?string {
+				return $this->default_entity_type;
+			}
+
+			private function handle_request( string $endpoint, WP_REST_Request $request ) {
+				return new WP_REST_Response( $this->responses[ $endpoint ], 200 );
+			}
+
+			private function handle_raw_array_request( string $endpoint, WP_REST_Request $request ) {
+				return array(
+					'id'   => 42,
+					'name' => 'Raw Array Item',
+				);
+			}
+
+			private function handle_non_array_request( WP_REST_Request $request ) {
+				$type = $request->get_param( 'type' );
+				switch ( $type ) {
+					case 'scalar':
+						return new WP_REST_Response( 'string_value', 200 );
+					case 'no_content':
+						return new WP_REST_Response( null, 204 );
+					default:
+						return new WP_REST_Response( null, 200 );
+				}
+			}
+
+			public function reinitialize_cache() {
+				$this->initialize_rest_api_cache();
+			}
+
+			// phpcs:enable Squiz.Commenting
+		};
+	}
+}