diff --git a/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx b/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx index 9e33ef62b..a9038bbf2 100644 --- a/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx @@ -100,7 +100,7 @@ const SearchResults = () => { if (isErrored) { return (
-

{__('An error occurred while fetching search results. Please try again.')}

+

{__('An error occurred while fetching search results. Please try again.', 'code-snippets')}

) } diff --git a/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx b/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx index d895f8e3a..e337aa409 100644 --- a/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx @@ -114,7 +114,7 @@ const DownloadOrViewButton: React.FC = ({ if (localSnippetId) { return ( - + {__('View', 'code-snippets')} ) diff --git a/src/js/types/Window.ts b/src/js/types/Window.ts index ad58b119a..001293444 100644 --- a/src/js/types/Window.ts +++ b/src/js/types/Window.ts @@ -43,7 +43,6 @@ declare global { welcome: string settings: string cloud: string - connectCloud: string } banner: { key: string diff --git a/src/php/Client/Cloud_API.php b/src/php/Client/Cloud_API.php index 3ca6d078e..abf973cdb 100644 --- a/src/php/Client/Cloud_API.php +++ b/src/php/Client/Cloud_API.php @@ -359,17 +359,14 @@ public function update_snippet_from_cloud( Cloud_Snippet $snippet_to_store ): ar } /** - * Transient key for cached cloud types (languages). - */ - private const TYPES_TRANSIENT_KEY = 'cs_cloud_types'; - - /** - * Transient key for cached cloud categories. + * Option key holding the current featured-snippets cache version. + * + * Bumped on flush so old transient keys become unreachable and expire naturally. */ - private const CATEGORIES_TRANSIENT_KEY = 'cs_cloud_categories'; + private const FEATURED_VERSION_OPTION = 'cs_featured_cache_version'; /** - * Transient key for cached featured snippets. + * Base transient key for cached featured snippets. */ private const FEATURED_TRANSIENT_KEY = 'cs_featured_snippets'; @@ -378,6 +375,40 @@ public function update_snippet_from_cloud( Cloud_Snippet $snippet_to_store ): ar */ private const FEATURED_MIN_TTL = 3600; + /** + * Get the current featured-snippets cache version, initialising it if absent. + * + * @return int + */ + private static function get_featured_cache_version(): string { + $version = get_transient( self::FEATURED_VERSION_OPTION ); + + if ( ! $version ) { + $version = (string) ( microtime( true ) * 1000 ); + set_transient( self::FEATURED_VERSION_OPTION, $version, MONTH_IN_SECONDS ); + } + + return $version; + } + + /** + * Build the transient key for a specific (version, page, per_page, filters) slot. + * + * @param int $page Page number (1-indexed). + * @param int $per_page Results per page. + * @param array $filters Filter values. + * + * @return string + */ + private static function build_featured_cache_key( int $page, int $per_page, array $filters ): string { + $active_filters = array_filter( $filters ); + $encoded = wp_json_encode( $active_filters ); + $filter_hash = md5( false === $encoded ? '' : $encoded ); + $version = self::get_featured_cache_version(); + + return self::FEATURED_TRANSIENT_KEY . "_v{$version}_p{$page}_pp{$per_page}_{$filter_hash}"; + } + /** * Retrieve featured snippets from the cloud API, with transient caching. * @@ -389,9 +420,7 @@ public function update_snippet_from_cloud( Cloud_Snippet $snippet_to_store ): ar */ public static function get_featured_snippets( int $page = 1, int $per_page = 10, array $filters = [] ): Cloud_Snippets { $per_page = min( self::MAX_RESULTS_PER_PAGE, max( 1, $per_page ) ); - $encoded = wp_json_encode( $filters ); - $filter_hash = md5( false === $encoded ? '' : $encoded ); - $cache_key = self::FEATURED_TRANSIENT_KEY . "_p{$page}_pp{$per_page}_{$filter_hash}"; + $cache_key = self::build_featured_cache_key( $page, $per_page, $filters ); $cached = get_transient( $cache_key ); @@ -445,89 +474,20 @@ public static function get_featured_snippets( int $page = 1, int $per_page = 10, return $result; } - /** - * Retrieve available snippet types (languages) from the cloud API, with transient caching. - * - * @return array List of types. - */ - public static function get_cloud_types(): array { - $cached = get_transient( self::TYPES_TRANSIENT_KEY ); - - if ( is_array( $cached ) ) { - return $cached; - } - - $url = self::get_cloud_api_url() . 'public/types'; - $response = wp_remote_get( $url ); - - if ( is_wp_error( $response ) ) { - return []; - } - - $json = self::unpack_request_json( $response ); - - if ( ! is_array( $json ) || ! isset( $json['data'] ) ) { - return []; - } - - $types = $json['data']; - set_transient( self::TYPES_TRANSIENT_KEY, $types, DAY_IN_SECONDS ); - - return $types; - } - - /** - * Retrieve available snippet categories from the cloud API, with transient caching. - * - * @return array List of categories. - */ - public static function get_cloud_categories(): array { - $cached = get_transient( self::CATEGORIES_TRANSIENT_KEY ); - - if ( is_array( $cached ) ) { - return $cached; - } - - $url = self::get_cloud_api_url() . 'public/categories'; - $response = wp_remote_get( $url ); - - if ( is_wp_error( $response ) ) { - return []; - } - - $json = self::unpack_request_json( $response ); - - if ( ! is_array( $json ) || ! isset( $json['data'] ) ) { - return []; - } - - $categories = $json['data']; - set_transient( self::CATEGORIES_TRANSIENT_KEY, $categories, DAY_IN_SECONDS ); - - return $categories; - } - /** * Refresh the cached synced data. * + * Bumps the featured-cache version counter so previously cached keys + * become unreachable and expire via WordPress's normal transient path. + * * @return void */ public function clear_caches() { - global $wpdb; - $this->cached_cloud_links = null; delete_transient( self::CLOUD_MAP_TRANSIENT_KEY ); - delete_transient( self::TYPES_TRANSIENT_KEY ); - delete_transient( self::CATEGORIES_TRANSIENT_KEY ); delete_transient( 'cs_codevault_snippets' ); - $wpdb->query( - $wpdb->prepare( - "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", - $wpdb->esc_like( '_transient_' . self::FEATURED_TRANSIENT_KEY ) . '%', - $wpdb->esc_like( '_transient_timeout_' . self::FEATURED_TRANSIENT_KEY ) . '%' - ) - ); + delete_transient( self::FEATURED_VERSION_OPTION ); } } diff --git a/src/php/Model/Cloud_Snippets.php b/src/php/Model/Cloud_Snippets.php index 97d660377..301e383f3 100644 --- a/src/php/Model/Cloud_Snippets.php +++ b/src/php/Model/Cloud_Snippets.php @@ -15,7 +15,6 @@ * @property int $total_pages Total number of available pages of items. * @property int $total_snippets Total number of available snippet items. * @property array $cloud_id_rev An array of all cloud snippet IDs and their revision numbers. - * @property bool $success If the request has any results. * @property array $available_filters An array of available filters that can be applied to the collection. */ class Cloud_Snippets extends Model { @@ -50,7 +49,7 @@ class Cloud_Snippets extends Model { /** * Class constructor. * - * @param Cloud_Snippet $initial_data Initial data. + * @param array|null $initial_data Initial data from the cloud API response. */ public function __construct( $initial_data = null ) { parent::__construct( $this->normalize_cloud_api( $initial_data ) ); @@ -103,7 +102,6 @@ protected function prepare_snippets( $snippets ): array { * @return mixed Normalized data array or original value when no normalization is required. */ private function normalize_cloud_api( $initial_data ) { - // pagination metadata is nested under a 'meta' key. if ( is_array( $initial_data ) && isset( $initial_data['meta'] ) ) { $meta = $initial_data['meta']; $normalized = []; diff --git a/src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php b/src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php index 79f699d96..b1d5afce0 100644 --- a/src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php +++ b/src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php @@ -32,22 +32,25 @@ final class Cloud_Snippets_REST_Controller extends Cloud_Collection_REST_Control /** * Common filter args shared across search and featured endpoints. * + * Each filter accepts a single numeric ID (e.g. category=12). The cloud API + * resolves IDs to the underlying name/slug. + * * @return array> */ private function get_filter_args(): array { return [ 'category' => [ - 'description' => esc_html__( 'Filter by category name (comma-separated).', 'code-snippets' ), + 'description' => esc_html__( 'Filter by category ID.', 'code-snippets' ), 'type' => 'string', 'default' => '', ], 'type' => [ - 'description' => esc_html__( 'Filter by language/type name (comma-separated).', 'code-snippets' ), + 'description' => esc_html__( 'Filter by language/type ID.', 'code-snippets' ), 'type' => 'string', 'default' => '', ], 'status' => [ - 'description' => esc_html__( 'Filter by status ID (comma-separated).', 'code-snippets' ), + 'description' => esc_html__( 'Filter by status ID.', 'code-snippets' ), 'type' => 'string', 'default' => '', ], @@ -155,30 +158,6 @@ public function register_routes() { ] ); - register_rest_route( - $this->namespace, - $this->rest_base . '/types', - [ - [ - 'methods' => WP_REST_Server::READABLE, - 'callback' => [ $this, 'get_types' ], - 'permission_callback' => [ $this, 'get_items_permissions_check' ], - ], - ] - ); - - register_rest_route( - $this->namespace, - $this->rest_base . '/categories', - [ - [ - 'methods' => WP_REST_Server::READABLE, - 'callback' => [ $this, 'get_categories' ], - 'permission_callback' => [ $this, 'get_items_permissions_check' ], - ], - ] - ); - register_rest_route( $this->namespace, $this->rest_base . '/(?P\d+)/download', @@ -239,24 +218,6 @@ public function get_featured_items( WP_REST_Request $request ): WP_REST_Response return $cloud_snippets->to_rest_response(); } - /** - * Retrieve available snippet types (languages) from the cloud API. - * - * @return WP_REST_Response - */ - public function get_types(): WP_REST_Response { - return rest_ensure_response( Cloud_API::get_cloud_types() ); - } - - /** - * Retrieve available snippet categories from the cloud API. - * - * @return WP_REST_Response - */ - public function get_categories(): WP_REST_Response { - return rest_ensure_response( Cloud_API::get_cloud_categories() ); - } - /** * Get the user's snippets per-page preference for Screen Options pagination. * diff --git a/tests/e2e/code-snippets-community-featured.spec.ts b/tests/e2e/code-snippets-community-featured.spec.ts index a0d3ade5e..53b4663aa 100644 --- a/tests/e2e/code-snippets-community-featured.spec.ts +++ b/tests/e2e/code-snippets-community-featured.spec.ts @@ -31,14 +31,13 @@ test.describe('Community Cloud Featured Snippets', () => { .waitFor({ state: 'hidden', timeout: TIMEOUTS.DEFAULT }) .catch(() => undefined) - const featuredHeading = page.locator('.cloud-featured-heading') + const featuredHeading = page.locator('.cloud-snippets-heading', { hasText: 'Featured Snippets' }) const headingVisible = await featuredHeading .waitFor({ state: 'visible', timeout: TIMEOUTS.SHORT }) .then(() => true) .catch(() => false) if (headingVisible) { - // Featured snippets loaded — verify the heading text. await expect(featuredHeading).toContainText('Featured Snippets') } else { // Cloud API unreachable — verify no crash: the search form is still functional. @@ -64,7 +63,7 @@ test.describe('Community Cloud Featured Snippets', () => { .waitFor({ state: 'hidden', timeout: TIMEOUTS.DEFAULT }) .catch(() => undefined) - // The "Featured Snippets" heading should no longer be visible. - await expect(page.locator('.cloud-featured-heading')).not.toBeVisible() + // The "Featured Snippets" heading should no longer be visible (search-mode heading replaces it). + await expect(page.locator('.cloud-snippets-heading', { hasText: 'Featured Snippets' })).not.toBeVisible() }) }) diff --git a/tests/phpunit/test-cloud-api-featured.php b/tests/phpunit/test-cloud-api-featured.php index bf6531033..1bde6ed81 100644 --- a/tests/phpunit/test-cloud-api-featured.php +++ b/tests/phpunit/test-cloud-api-featured.php @@ -63,9 +63,14 @@ public function tear_down() { * @return string */ private function transient_key( int $page = 1, int $per_page = 10, array $filters = [] ): string { - $encoded = wp_json_encode( $filters ); + $active_filters = array_filter( $filters ); + $encoded = wp_json_encode( $active_filters ); $hash = md5( false === $encoded ? '' : $encoded ); - return "cs_featured_snippets_p{$page}_pp{$per_page}_{$hash}"; + $version = get_transient( 'cs_featured_cache_version' ); + if ( ! $version ) { + $version = (string) ( microtime( true ) * 1000 ); + } + return "cs_featured_snippets_v{$version}_p{$page}_pp{$per_page}_{$hash}"; } /** @@ -75,6 +80,7 @@ private function transient_key( int $page = 1, int $per_page = 10, array $filter */ private function clear_featured_transients(): void { delete_transient( $this->transient_key() ); + delete_transient( 'cs_featured_cache_version' ); } /** @@ -340,4 +346,40 @@ public function test_available_filters_preserved(): void { $this->assertArrayHasKey( 'types', $result->available_filters ); $this->assertArrayHasKey( 'statuses', $result->available_filters ); } + + /** + * Calling clear_caches() causes the next get_featured_snippets() to miss cache and re-fetch. + * + * @return void + */ + public function test_clear_caches_invalidates_featured_cache(): void { + Cloud_API::get_featured_snippets(); + $this->assertSame( 1, $this->http_request_count ); + + $api = new Cloud_API(); + $api->clear_caches(); + + Cloud_API::get_featured_snippets(); + $this->assertSame( 2, $this->http_request_count, 'Expected a fresh HTTP request after cache invalidation.' ); + } + + /** + * Empty filter values produce the same cache key as omitted filters. + * + * @return void + */ + public function test_empty_filters_produce_same_cache_key(): void { + $key_empty = $this->transient_key( + 1, + 10, + [ + 'category' => '', + 'type' => '', + 'status' => '', + ] + ); + $key_none = $this->transient_key( 1, 10, [] ); + + $this->assertSame( $key_none, $key_empty, 'Empty filter values should hash identically to no filters.' ); + } }