Commit 2ef538ce2c for woocommerce

commit 2ef538ce2cfc6a9b1cc9f62512ec2dc4f97fb195
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Tue Nov 25 11:20:57 2025 +0100

    Add REST API backend caching and cache control headers configuration (#61986)

    Co-authored-by: botwoo <102544806+botwoo@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/pr-61986 b/plugins/woocommerce/changelog/pr-61986
new file mode 100644
index 0000000000..6bb32fd470
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-61986
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Add REST API backend caching and cache control headers configuration
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-settings.php b/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
index f83af43c96..268a7be6b0 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
@@ -294,6 +294,10 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) :
 					$value['value'] = self::get_option( $value['id'], $value['default'] );
 				}

+				if ( ! is_null( $value['fixed_value'] ?? null ) ) {
+					$value['value'] = $value['fixed_value'];
+				}
+
 				// Custom attribute handling.
 				$custom_attributes = array();

@@ -336,6 +340,20 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) :
 						echo '</td></tr>';
 						break;

+					// Notice.
+					case 'notice':
+						$notice_type = $value['notice_type'] ?? 'info';
+						$notice_text = $value['text'] ?? '';
+
+						?>
+						</table>
+						<div class="notice notice-<?php echo esc_attr( $notice_type ); ?> inline">
+							<p><?php echo wp_kses_post( $notice_text ); ?></p>
+						</div>
+						<table class="form-table" role="presentation">
+						<?php
+						break;
+
 					// Section Ends.
 					case 'sectionend':
 						if ( ! empty( $value['id'] ) ) {
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-advanced.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-advanced.php
index 4ff8a01e9c..6c0c347a0c 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-advanced.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-advanced.php
@@ -5,6 +5,7 @@
  * @package  WooCommerce\Admin
  */

+use Automattic\WooCommerce\Internal\Features\FeaturesController;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;

 defined( 'ABSPATH' ) || exit;
@@ -46,13 +47,19 @@ class WC_Settings_Advanced extends WC_Settings_Page {
 	 */
 	protected function get_own_sections() {
 		$sections = array(
-			''                => __( 'Page setup', 'woocommerce' ),
-			'keys'            => __( 'REST API', 'woocommerce' ),
-			'webhooks'        => __( 'Webhooks', 'woocommerce' ),
-			'legacy_api'      => __( 'Legacy API', 'woocommerce' ),
-			'woocommerce_com' => __( 'WooCommerce.com', 'woocommerce' ),
+			''     => __( 'Page setup', 'woocommerce' ),
+			'keys' => __( 'REST API keys', 'woocommerce' ),
 		);

+		$features_controller = wc_get_container()->get( FeaturesController::class );
+		if ( $features_controller->feature_is_enabled( 'rest_api_caching' ) ) {
+			$sections['rest_api_caching'] = __( 'REST API caching', 'woocommerce' );
+		}
+
+		$sections['webhooks']        = __( 'Webhooks', 'woocommerce' );
+		$sections['legacy_api']      = __( 'Legacy API', 'woocommerce' );
+		$sections['woocommerce_com'] = __( 'WooCommerce.com', 'woocommerce' );
+
 		if ( FeaturesUtil::feature_is_enabled( 'blueprint' ) ) {
 			$sections['blueprint'] = __( 'Blueprint (beta)', 'woocommerce' );
 		}
@@ -439,6 +446,73 @@ class WC_Settings_Advanced extends WC_Settings_Page {
 		return apply_filters( 'woocommerce_settings_rest_api', $settings );
 	}

+	/**
+	 * Get settings for the REST API caching section.
+	 *
+	 * @return array
+	 */
+	protected function get_settings_for_rest_api_caching_section() {
+		$has_object_cache = wp_using_ext_object_cache();
+
+		$settings = array(
+			array(
+				'title' => __( 'REST API response cache', 'woocommerce' ),
+				'type'  => 'title',
+				'desc'  => __( 'These settings control backend caching and cache control headers for REST API responses.', 'woocommerce' ),
+				'id'    => 'rest_api_cache_options',
+			),
+		);
+
+		if ( ! $has_object_cache ) {
+			$settings[] = array(
+				'type'        => 'notice',
+				'id'          => 'rest_api_cache_warning',
+				'notice_type' => 'warning',
+				'text'        => sprintf(
+					/* translators: %1$s and %2$s are opening and closing <a> tags */
+					__( 'Backend caching requires a WordPress object cache plugin (Redis, Memcached, etc.) to be installed and active. %1$sLearn more about object caching%2$s.', 'woocommerce' ),
+					'<a href="https://developer.wordpress.org/reference/classes/wp_object_cache/" target="_blank">',
+					'</a>'
+				),
+			);
+		}
+
+		$backend_caching_setting = array(
+			'title'       => __( 'Enable backend caching', 'woocommerce' ),
+			'desc'        => __( 'Cache REST API responses on the server', 'woocommerce' ),
+			'id'          => 'woocommerce_rest_api_enable_backend_caching',
+			'type'        => 'checkbox',
+			'default'     => 'no',
+			'disabled'    => ! $has_object_cache,
+			'fixed_value' => $has_object_cache ? null : 'no',
+			'desc_tip'    => __( 'Enables responses for REST API endpoints configured as cacheable. Requires an external object cache.<br/>This setting should be enabled only if no other plugins that handle caching are active.', 'woocommerce' ),
+		);
+
+		$settings[] = $backend_caching_setting;
+
+		$settings[] = array(
+			'title'    => __( 'Enable cache control headers', 'woocommerce' ),
+			'desc'     => __( 'Send cache control headers and support 304 Not Modified responses', 'woocommerce' ),
+			'id'       => 'woocommerce_rest_api_enable_cache_headers',
+			'type'     => 'checkbox',
+			'default'  => 'yes',
+			'desc_tip' => __( 'Enables including ETag and Cache-Control headers, and returning 304 Not Modified responses, for REST API endpoints configured as cacheable.', 'woocommerce' ),
+		);
+
+		$settings[] = array(
+			'type' => 'sectionend',
+			'id'   => 'rest_api_cache_options',
+		);
+
+		/**
+		 * Filter REST API cache settings.
+		 *
+		 * @since 10.5.0
+		 * @param array $settings REST API cache settings.
+		 */
+		return apply_filters( 'woocommerce_rest_api_cache_settings', $settings );
+	}
+
 	/**
 	 * Get settings for the Blueprint section.
 	 *
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 4288a3dcaf..44d7e649c6 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -558,6 +558,20 @@ class FeaturesController {
 				'skip_compatibility_checks'    => true,
 				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
 			),
+			'rest_api_caching'              => array(
+				'name'                         => __( 'REST API Caching', 'woocommerce' ),
+				'description'                  => sprintf(
+					/* translators: %1$s and %2$s are opening and closing <a> tags */
+					__( 'Enable backend caching and cache control headers for REST API responses via the <code>RestApiCache</code> trait. ⚙️ %1$sConfiguration%2$s', 'woocommerce' ),
+					'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=advanced&section=rest_api_caching' ) . '">',
+					'</a>'
+				),
+				'enabled_by_default'           => false,
+				'is_experimental'              => true,
+				'disable_ui'                   => false,
+				'skip_compatibility_checks'    => true,
+				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+			),
 		);

 		if ( ! $tracking_enabled ) {
diff --git a/plugins/woocommerce/src/Internal/Traits/RestApiCache.php b/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
index e3430379d9..0f60800b2f 100644
--- a/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
+++ b/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 namespace Automattic\WooCommerce\Internal\Traits;

 use Automattic\WooCommerce\Internal\Caches\VersionStringGenerator;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\Utilities\CallbackUtil;
 use WP_REST_Request;
@@ -144,13 +145,29 @@ trait RestApiCache {
 	 */
 	private $is_handling_cached_endpoint = false;

+	/**
+	 * Whether the REST API caching feature is enabled.
+	 *
+	 * @var bool
+	 */
+	private bool $rest_api_caching_feature_enabled = false;
+
 	/**
 	 * 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;
+		$features_controller = wc_get_container()->get( FeaturesController::class );
+
+		$this->rest_api_caching_feature_enabled = $features_controller->feature_is_enabled( 'rest_api_caching' );
+		if ( ! $this->rest_api_caching_feature_enabled ) {
+			return;
+		}
+
+		$generator = wc_get_container()->get( VersionStringGenerator::class );
+
+		$backend_caching_enabled        = 'yes' === get_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+		$this->version_string_generator = ( $backend_caching_enabled && $generator->can_use() ) ? $generator : null;

 		add_filter( 'rest_send_nocache_headers', array( $this, 'handle_rest_send_nocache_headers' ), 10, 1 );
 	}
@@ -172,14 +189,19 @@ trait RestApiCache {
 	 * @return callable Wrapped callback.
 	 */
 	protected function with_cache( callable $callback, array $config = array() ): callable {
-		return fn( $request ) => $this->handle_cacheable_request( $request, $callback, $config );
+		return $this->rest_api_caching_feature_enabled
+			? fn( $request ) => $this->handle_cacheable_request( $request, $callback, $config )
+			: fn( $request ) => call_user_func( $callback, $request );
 	}

 	/**
 	 * 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.
+	 * Strategy:
+	 * - If backend caching is enabled: Try to use cached response if available, otherwise execute
+	 *   the callback and cache the response.
+	 * - If only cache headers are enabled: Execute the callback, generate ETag, and return 304
+	 *   if the client's ETag matches.
 	 *
 	 * @param WP_REST_Request $request  The request object.
 	 * @param callable        $callback The original endpoint callback.
@@ -187,7 +209,10 @@ trait RestApiCache {
 	 * @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 ) ) {
+		$backend_caching_enabled = ! is_null( $this->version_string_generator );
+		$cache_headers_enabled   = 'yes' === get_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+
+		if ( ! $backend_caching_enabled && ! $cache_headers_enabled ) {
 			return call_user_func( $callback, $request );
 		}

@@ -208,16 +233,20 @@ trait RestApiCache {

 		$this->is_handling_cached_endpoint = true;

-		$cached_response = $this->get_cached_response( $request, $cached_config );
+		if ( $backend_caching_enabled ) {
+			$cached_response = $this->get_cached_response( $request, $cached_config, $cache_headers_enabled );

-		if ( $cached_response ) {
-			$cached_response->header( 'X-WC-Cache', 'HIT' );
-			return $cached_response;
+			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 );
+		return $backend_caching_enabled
+			? $this->maybe_cache_response( $request, $authoritative_response, $cached_config, $cache_headers_enabled )
+			: $this->maybe_add_cache_headers( $request, $authoritative_response, $cached_config );
 	}

 	/**
@@ -291,7 +320,7 @@ trait RestApiCache {
 	}

 	/**
-	 * Cache the response if it's successful and return it with appropriate headers.
+	 * Cache the response if it's successful and optionally add cache 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.
@@ -299,12 +328,13 @@ trait RestApiCache {
 	 * 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().
+	 * @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().
+	 * @param bool                                    $add_cache_headers  Whether to add cache control headers.
 	 * @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 ) {
+	private function maybe_cache_response( WP_REST_Request $request, $response, array $cached_config, bool $add_cache_headers ) {
 		if ( is_wp_error( $response ) ) {
 			return $response;
 		}
@@ -344,17 +374,95 @@ trait RestApiCache {
 			);

 			$cached = true;
-
-			$is_user_logged_in = wc_get_container()->get( LegacyProxy::class )->call_function( 'is_user_logged_in' );
-			$cache_visibility  = $cached_config['vary_by_user'] && $is_user_logged_in ? 'private' : 'public';
-			$response->header( 'ETag', $etag );
-			$response->header( 'Cache-Control', $cache_visibility . ', must-revalidate, max-age=' . $cached_config['cache_ttl'] );
 		}

 		$response->header( 'X-WC-Cache', $cached ? 'MISS' : 'SKIP' );
+
+		return $add_cache_headers ?
+			$this->maybe_add_cache_headers( $request, $response, $cached_config ) :
+			$response;
+	}
+
+	/**
+	 * Add cache control headers to a response.
+	 *
+	 * This method generates an ETag from the response data and returns a 304 Not Modified
+	 * if the client's If-None-Match header matches. It can be used both with and without
+	 * backend caching.
+	 *
+	 * @param WP_REST_Request                         $request       The request object.
+	 * @param WP_REST_Response|\WP_Error|array|object $response      The response to add headers to.
+	 * @param array                                   $cached_config Caching configuration from build_cache_config().
+	 * @return WP_REST_Response|\WP_Error The response with cache headers.
+	 */
+	private function maybe_add_cache_headers( WP_REST_Request $request, $response, array $cached_config ) {
+		if ( is_wp_error( $response ) ) {
+			return $response;
+		}
+
+		$response = rest_ensure_response( $response );
+
+		$status = $response->get_status();
+		if ( $status < 200 || $status > 299 ) {
+			return $response;
+		}
+
+		$response_data      = $response->get_data();
+		$response_etag_data = is_array( $response_data ) ? $this->get_data_for_etag( $response_data, $request, $cached_config['endpoint_id'] ) : $response_data;
+		$response_etag      = '"' . md5( $cached_config['cache_key'] . wp_json_encode( $response_etag_data ) ) . '"';
+
+		$request_etag = $request->get_header( 'if-none-match' );
+
+		$is_user_logged_in   = wc_get_container()->get( LegacyProxy::class )->call_function( 'is_user_logged_in' );
+		$cache_visibility    = $cached_config['vary_by_user'] && $is_user_logged_in ? 'private' : 'public';
+		$cache_control_value = $cache_visibility . ', must-revalidate, max-age=' . $cached_config['cache_ttl'];
+
+		if ( ! empty( $response_etag ) && $request_etag === $response_etag ) {
+			$not_modified_response = $this->create_not_modified_response( $response_etag, $cache_control_value, $request, $cached_config['endpoint_id'] );
+			if ( $not_modified_response ) {
+				return $not_modified_response;
+			}
+		}
+
+		$response->header( 'ETag', $response_etag );
+		$response->header( 'Cache-Control', $cache_control_value );
+
+		if ( ! array_key_exists( 'X-WC-Cache', $response->get_headers() ) ) {
+			$response->header( 'X-WC-Cache', 'HEADERS' );
+		}
+
 		return $response;
 	}

+	/**
+	 * Create a 304 Not Modified response if allowed by filters.
+	 *
+	 * @param string          $etag                The ETag value.
+	 * @param string          $cache_control_value The Cache-Control header value.
+	 * @param WP_REST_Request $request             The request object.
+	 * @param string|null     $endpoint_id         The endpoint identifier.
+	 * @return WP_REST_Response|null 304 response if allowed, null otherwise.
+	 */
+	private function create_not_modified_response( string $etag, string $cache_control_value, WP_REST_Request $request, ?string $endpoint_id ): ?WP_REST_Response {
+		$response = new WP_REST_Response( null, 304 );
+		$response->header( 'ETag', $etag );
+		$response->header( 'Cache-Control', $cache_control_value );
+		$response->header( 'X-WC-Cache', 'MATCH' );
+
+		/**
+		 * Filter the 304 Not Modified response before sending.
+		 *
+		 * @since 10.5.0
+		 *
+		 * @param WP_REST_Response|false $response    The 304 response object, or false to prevent sending it.
+		 * @param WP_REST_Request        $request     The request object.
+		 * @param string|null            $endpoint_id The endpoint identifier.
+		 */
+		$filtered_response = apply_filters( 'woocommerce_rest_api_not_modified_response', $response, $request, $endpoint_id );
+
+		return false === $filtered_response ? null : rest_ensure_response( $filtered_response );
+	}
+
 	/**
 	 * Get the default type for entities included in responses.
 	 *
@@ -721,11 +829,12 @@ trait RestApiCache {
 	/**
 	 * Get a cached response, but only if it's valid (otherwise the cached response will be invalidated).
 	 *
-	 * @param WP_REST_Request $request       The request object.
-	 * @param array           $cached_config Built caching configuration from build_cache_config().
+	 * @param WP_REST_Request $request              The request object.
+	 * @param array           $cached_config        Built caching configuration from build_cache_config().
+	 * @param bool            $cache_headers_enabled Whether to add cache control headers.
 	 * @return WP_REST_Response|null Cached response, or null if not available or has been invalidated.
 	 */
-	private function get_cached_response( WP_REST_Request $request, array $cached_config ): ?WP_REST_Response {
+	private function get_cached_response( WP_REST_Request $request, array $cached_config, bool $cache_headers_enabled ): ?WP_REST_Response {
 		$cache_key      = $cached_config['cache_key'];
 		$entity_type    = $cached_config['entity_type'];
 		$cache_ttl      = $cached_config['cache_ttl'];
@@ -768,26 +877,36 @@ trait RestApiCache {

 		// Check if client sent an ETag and it matches - if so, return 304 Not Modified.
 		$cached_etag  = $cached['etag'] ?? '';
-		$request_etag = $request->get_header( 'if_none_match' );
+		$request_etag = $request->get_header( 'if-none-match' );

-		$is_user_logged_in = wc_get_container()->get( LegacyProxy::class )->call_function( 'is_user_logged_in' );
-		$cache_visibility  = $cached_config['vary_by_user'] && $is_user_logged_in ? 'private' : 'public';
-		$response_headers  = array();
+		$response_headers = array();

-		if ( ! empty( $cached_etag ) ) {
-			$response_headers['ETag'] = $cached_etag;
-		}
-		$response_headers['Cache-Control'] = $cache_visibility . ', must-revalidate, max-age=' . $cache_ttl;
-
-		// If the server adds a 'Date' header by itself there will be two such headers in the response.
-		// To help disambiguate them, we add also an 'X-WC-Date' header with the proper value.
-		$created_at                    = gmdate( 'D, d M Y H:i:s', $cached['created_at'] ) . ' GMT';
-		$response_headers['Date']      = $created_at;
-		$response_headers['X-WC-Date'] = $created_at;
+		if ( $cache_headers_enabled ) {
+			$is_user_logged_in = wc_get_container()->get( LegacyProxy::class )->call_function( 'is_user_logged_in' );
+			$cache_visibility  = $cached_config['vary_by_user'] && $is_user_logged_in ? 'private' : 'public';

-		if ( ! empty( $cached_etag ) && $request_etag === $cached_etag ) {
-			return new WP_REST_Response( null, 304, $response_headers );
+			if ( ! empty( $cached_etag ) ) {
+				$response_headers['ETag'] = $cached_etag;
+			}
+			$response_headers['Cache-Control'] = $cache_visibility . ', must-revalidate, max-age=' . $cache_ttl;
+
+			// If the server adds a 'Date' header by itself there will be two such headers in the response.
+			// To help disambiguate them, we add also an 'X-WC-Date' header with the proper value.
+			$created_at                    = gmdate( 'D, d M Y H:i:s', $cached['created_at'] ) . ' GMT';
+			$response_headers['Date']      = $created_at;
+			$response_headers['X-WC-Date'] = $created_at;
+
+			if ( ! empty( $cached_etag ) && $request_etag === $cached_etag ) {
+				$cache_control         = $response_headers['Cache-Control'] ?? '';
+				$not_modified_response = $this->create_not_modified_response( $cached_etag, $cache_control, $request, $cached_config['endpoint_id'] );
+				if ( $not_modified_response ) {
+					$not_modified_response->header( 'Date', $response_headers['Date'] );
+					$not_modified_response->header( 'X-WC-Date', $response_headers['X-WC-Date'] );
+					return $not_modified_response;
+				}
+			}
 		}
+
 		$response = new WP_REST_Response( $cached['data'], $cached['status_code'] ?? 200 );

 		foreach ( $response_headers as $name => $value ) {
diff --git a/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php b/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
index 4f1fa406e9..9bc2fb799b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
@@ -47,14 +47,21 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
 	public function setUp(): void {
 		parent::setUp();

+		remove_all_filters( 'woocommerce_rest_api_not_modified_response' );
+
 		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
 			array(
 				'time'                      => fn() => $this->fixed_time,
 				'get_current_user_id'       => fn() => 1,
+				'is_user_logged_in'         => fn() => true,
 				'wp_using_ext_object_cache' => fn() => true,
 			)
 		);

+		update_option( 'woocommerce_feature_rest_api_caching_enabled', 'yes' );
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+		update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+
 		// Needed to ensure VersionStringGenerator uses the mocked wp_using_ext_object_cache.
 		$this->reset_container_resolutions();

@@ -71,6 +78,8 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
 		global $wp_rest_server;
 		$wp_rest_server = null;

+		remove_all_filters( 'woocommerce_rest_api_not_modified_response' );
+
 		parent::tearDown();
 	}

@@ -245,7 +254,7 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Caching is skipped when entity versions cache is disabled.
+	 * @testdox Backend caching is skipped when object cache is disabled, but cache headers still work.
 	 */
 	public function test_caching_skipped_when_entity_cache_disabled() {
 		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
@@ -255,8 +264,14 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
 		$this->sut->reinitialize_cache();

 		$response = $this->query_endpoint( 'single_entity' );
-		$this->assertCacheHeader( $response, null );
+
+		// Backend caching should be skipped but cache headers should still work.
 		$this->assertCount( 0, $this->get_all_cache_keys() );
+
+		$this->assertCacheHeader( $response, 'HEADERS' );
+		$headers = $response->get_headers();
+		$this->assertArrayHasKey( 'ETag', $headers );
+		$this->assertArrayHasKey( 'Cache-Control', $headers );
 	}

 	/**
@@ -1135,6 +1150,15 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
 		$headers = $response->get_headers();
 		$this->assertArrayHasKey( 'ETag', $headers );
 		$this->assertMatchesRegularExpression( '/^"[a-f0-9]{32}"$/', $headers['ETag'] );
+
+		$cache_keys = $this->get_all_cache_keys();
+		$this->assertCount( 1, $cache_keys );
+		$cache_key    = $cache_keys[0];
+		$cached_entry = wp_cache_get( $cache_key, 'woocommerce_rest_api_cache' );
+
+		$this->assertIsArray( $cached_entry );
+		$this->assertArrayHasKey( 'etag', $cached_entry, 'ETag should be stored in cache entry' );
+		$this->assertSame( $headers['ETag'], $cached_entry['etag'], 'Cached ETag should match response ETag' );
 	}

 	/**
@@ -1300,4 +1324,285 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
 		$this->assertStringNotContainsString( 'no-cache', $headers['Cache-Control'] );
 		$this->assertStringNotContainsString( 'no-store', $headers['Cache-Control'] );
 	}
+
+	/**
+	 * @testdox Cache headers work without backend caching when cache headers setting is enabled.
+	 */
+	public function test_cache_headers_without_backend_caching() {
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+		update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+		$this->sut->reinitialize_cache();
+
+		$response1 = $this->query_endpoint( 'single_entity' );
+
+		// Should not cache in backend, but should add cache headers.
+		$this->assertCount( 0, $this->get_all_cache_keys() );
+
+		$headers = $response1->get_headers();
+		$this->assertArrayHasKey( 'ETag', $headers );
+		$this->assertArrayHasKey( 'Cache-Control', $headers );
+		$this->assertCacheHeader( $response1, 'HEADERS' );
+
+		// Should return 304 on subsequent request with matching ETag.
+		$etag    = $headers['ETag'];
+		$request = new WP_REST_Request( 'GET', '/wc/v3/rest_api_cache_test/single_entity' );
+		$request->set_header( 'If-None-Match', $etag );
+		$response2 = $this->server->dispatch( $request );
+
+		$this->assertSame( 304, $response2->get_status() );
+		$this->assertCacheHeader( $response2, 'MATCH' );
+	}
+
+	/**
+	 * @testdox Backend caching works without cache headers when backend caching is enabled.
+	 */
+	public function test_backend_caching_without_cache_headers() {
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+		update_option( 'woocommerce_rest_api_enable_cache_headers', 'no' );
+		$this->sut->reinitialize_cache();
+
+		$response1 = $this->query_endpoint( 'single_entity' );
+
+		// Should cache in backend, but should not add ETag or Cache-Control headers.
+		$this->assertCount( 1, $this->get_all_cache_keys() );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$headers = $response1->get_headers();
+		$this->assertArrayNotHasKey( 'ETag', $headers );
+		$this->assertArrayNotHasKey( 'Cache-Control', $headers );
+
+		// Second request should return from cache.
+		$response2 = $this->query_endpoint( 'single_entity' );
+		$this->assertCacheHeader( $response2, 'HIT' );
+
+		// Still no ETag or Cache-Control headers.
+		$headers2 = $response2->get_headers();
+		$this->assertArrayNotHasKey( 'ETag', $headers2 );
+		$this->assertArrayNotHasKey( 'Cache-Control', $headers2 );
+	}
+
+	/**
+	 * @testdox Both features work together when both settings are enabled.
+	 */
+	public function test_both_features_enabled() {
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+		update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+		$this->sut->reinitialize_cache();
+
+		$response1 = $this->query_endpoint( 'single_entity' );
+
+		// Should cache in backend and add cache headers.
+		$this->assertCount( 1, $this->get_all_cache_keys() );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$headers = $response1->get_headers();
+		$this->assertArrayHasKey( 'ETag', $headers );
+		$this->assertArrayHasKey( 'Cache-Control', $headers );
+
+		// Second request should return from cache with headers.
+		$response2 = $this->query_endpoint( 'single_entity' );
+		$this->assertCacheHeader( $response2, 'HIT' );
+
+		$headers2 = $response2->get_headers();
+		$this->assertArrayHasKey( 'ETag', $headers2 );
+		$this->assertArrayHasKey( 'Cache-Control', $headers2 );
+	}
+
+	/**
+	 * @testdox Neither feature works when both settings are disabled.
+	 */
+	public function test_both_features_disabled() {
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+		update_option( 'woocommerce_rest_api_enable_cache_headers', 'no' );
+		$this->sut->reinitialize_cache();
+
+		$response = $this->query_endpoint( 'single_entity' );
+
+		// Should not cache in backend, nor add cache headers.
+		$this->assertCount( 0, $this->get_all_cache_keys() );
+
+		$headers = $response->get_headers();
+		$this->assertArrayNotHasKey( 'ETag', $headers );
+		$this->assertArrayNotHasKey( 'Cache-Control', $headers );
+
+		// Should not have X-WC-Cache header.
+		$this->assertArrayNotHasKey( 'X-WC-Cache', $headers );
+	}
+
+	/**
+	 * @testdox Filter woocommerce_rest_api_not_modified_response can prevent 304 response.
+	 */
+	public function test_filter_can_prevent_304_response() {
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+		update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+		$this->sut->reinitialize_cache();
+
+		$response1 = $this->query_endpoint( 'single_entity' );
+		$etag      = $response1->get_headers()['ETag'];
+
+		$filter_called     = false;
+		$received_response = null;
+		$filter            = function ( $response, $request, $endpoint_id ) use ( &$filter_called, &$received_response ) {
+			unset( $request, $endpoint_id ); // Avoid parameter not used PHPCS errors.
+			$filter_called     = true;
+			$received_response = $response;
+			return false;
+		};
+		add_filter( 'woocommerce_rest_api_not_modified_response', $filter, 10, 3 );
+
+		// Request with matching ETag.
+		$request = new WP_REST_Request( 'GET', '/wc/v3/rest_api_cache_test/single_entity' );
+		$request->set_header( 'If-None-Match', $etag );
+		$response2 = $this->server->dispatch( $request );
+
+		// Filter should have been called with a 304 response.
+		$this->assertTrue( $filter_called );
+		$this->assertInstanceOf( WP_REST_Response::class, $received_response );
+		$this->assertSame( 304, $received_response->get_status() );
+
+		// Should return 200 with full data instead of 304.
+		$this->assertSame( 200, $response2->get_status() );
+		$this->assertNotNull( $response2->get_data() );
+		$this->assertCacheHeader( $response2, 'HEADERS' );
+
+		remove_filter( 'woocommerce_rest_api_not_modified_response', $filter, 10 );
+	}
+
+	/**
+	 * @testdox Filter woocommerce_rest_api_not_modified_response can modify 304 response.
+	 */
+	public function test_filter_can_modify_304_response() {
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+		update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+		$this->sut->reinitialize_cache();
+
+		$response1 = $this->query_endpoint( 'single_entity' );
+		$etag      = $response1->get_headers()['ETag'];
+
+		$filter = function ( $response, $request, $endpoint_id ) {
+			unset( $request, $endpoint_id ); // Avoid parameter not used PHPCS errors.
+			$response->header( 'X-Custom-Header', 'custom-value' );
+			return $response;
+		};
+		add_filter( 'woocommerce_rest_api_not_modified_response', $filter, 10, 3 );
+
+		// Request with matching ETag.
+		$request = new WP_REST_Request( 'GET', '/wc/v3/rest_api_cache_test/single_entity' );
+		$request->set_header( 'If-None-Match', $etag );
+		$response2 = $this->server->dispatch( $request );
+
+		$this->assertSame( 304, $response2->get_status() );
+
+		// Should have custom header.
+		$headers = $response2->get_headers();
+		$this->assertArrayHasKey( 'X-Custom-Header', $headers );
+		$this->assertSame( 'custom-value', $headers['X-Custom-Header'] );
+
+		remove_filter( 'woocommerce_rest_api_not_modified_response', $filter, 10 );
+	}
+
+	/**
+	 * @testdox Filter woocommerce_rest_api_not_modified_response is called for cached 304 responses.
+	 */
+	public function test_filter_called_for_cached_304_responses() {
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+		update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+		$this->sut->reinitialize_cache();
+
+		// First request to cache.
+		$response1 = $this->query_endpoint( 'single_entity' );
+		$etag      = $response1->get_headers()['ETag'];
+
+		// Second request to populate cache.
+		$this->query_endpoint( 'single_entity' );
+
+		$filter_called = false;
+		$filter        = function ( $response, $request, $endpoint_id ) use ( &$filter_called ) {
+			unset( $response, $request, $endpoint_id ); // Avoid parameter not used PHPCS errors.
+			$filter_called = true;
+			return false;
+		};
+		add_filter( 'woocommerce_rest_api_not_modified_response', $filter, 10, 3 );
+
+		// Third request with matching ETag (should be served from cache).
+		$request = new WP_REST_Request( 'GET', '/wc/v3/rest_api_cache_test/single_entity' );
+		$request->set_header( 'If-None-Match', $etag );
+		$response3 = $this->server->dispatch( $request );
+
+		$this->assertTrue( $filter_called );
+
+		// Should return 200 with full cached data instead of 304.
+		$this->assertSame( 200, $response3->get_status() );
+		$this->assertNotNull( $response3->get_data() );
+		$this->assertCacheHeader( $response3, 'HIT' );
+
+		remove_filter( 'woocommerce_rest_api_not_modified_response', $filter, 10 );
+	}
+
+	/**
+	 * @testdox X-WC-Cache header shows HEADERS when only cache headers are enabled.
+	 */
+	public function test_x_wc_cache_headers_value() {
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+		update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+		$this->sut->reinitialize_cache();
+
+		$response = $this->query_endpoint( 'single_entity' );
+
+		$this->assertCacheHeader( $response, 'HEADERS' );
+	}
+
+	/**
+	 * @testdox X-WC-Cache header shows MATCH on 304 response.
+	 */
+	public function test_x_wc_cache_match_value() {
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
+		update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+		$this->sut->reinitialize_cache();
+
+		$response1 = $this->query_endpoint( 'single_entity' );
+		$etag      = $response1->get_headers()['ETag'];
+
+		$request = new WP_REST_Request( 'GET', '/wc/v3/rest_api_cache_test/single_entity' );
+		$request->set_header( 'If-None-Match', $etag );
+		$response2 = $this->server->dispatch( $request );
+
+		$this->assertCacheHeader( $response2, 'MATCH' );
+	}
+
+	/**
+	 * @testdox Caching is completely bypassed when rest_api_caching feature is disabled (even with caching options enabled).
+	 */
+	public function test_caching_bypassed_when_feature_disabled() {
+		update_option( 'woocommerce_rest_api_enable_backend_caching', 'yes' );
+		update_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );
+		update_option( 'woocommerce_feature_rest_api_caching_enabled', 'no' );
+		$this->sut->reinitialize_cache();
+
+		$this->reset_rest_server();
+
+		$response = $this->query_endpoint( 'single_entity' );
+
+		$this->assertCount( 0, $this->get_all_cache_keys() );
+
+		$headers = $response->get_headers();
+		$this->assertArrayNotHasKey( 'X-WC-Cache', $headers );
+		$this->assertArrayNotHasKey( 'ETag', $headers );
+		$this->assertArrayNotHasKey( 'Cache-Control', $headers );
+
+		$this->assertEquals( $this->sut->responses['single_entity'], $response->get_data() );
+	}
+
+	/**
+	 * @testdox rest_send_nocache_headers filter is not registered when feature is disabled.
+	 */
+	public function test_nocache_headers_filter_not_registered_when_feature_disabled() {
+		update_option( 'woocommerce_feature_rest_api_caching_enabled', 'no' );
+
+		// Create a new controller with the feature disabled to test that the filter is not registered.
+		$controller = $this->create_test_controller();
+
+		$has_filter = has_filter( 'rest_send_nocache_headers', array( $controller, 'handle_rest_send_nocache_headers' ) );
+		$this->assertFalse( $has_filter );
+	}
 }