diff --git a/src/php/Client/Cloud_API.php b/src/php/Client/Cloud_API.php index c3ba08f1e..2c7b85960 100644 --- a/src/php/Client/Cloud_API.php +++ b/src/php/Client/Cloud_API.php @@ -24,6 +24,12 @@ class Cloud_API { */ public const MAX_RESULTS_PER_PAGE = 100; + /** + * Request timeout, in seconds, for the cloud search endpoint. Higher than WordPress's 5s + * default because search can be slow on a cold cache and would otherwise time out. + */ + private const SEARCH_REQUEST_TIMEOUT = 15; + /** * Key used to access the local-to-cloud map transient data. */ @@ -249,9 +255,24 @@ public static function fetch_search_results( string $search_method, string $sear $api_url = add_query_arg( $params, self::get_cloud_api_url() . 'public/search' ); - $raw = self::unpack_request_json( wp_remote_get( $api_url ) ); + // The search endpoint can be slow on a cold cache; allow more time than WordPress's + // default 5s request timeout so the request is not cut short and returned as empty. + $response = wp_remote_get( $api_url, [ 'timeout' => self::SEARCH_REQUEST_TIMEOUT ] ); + + if ( is_wp_error( $response ) ) { + return new Cloud_Snippets(); + } + + $json = json_decode( wp_remote_retrieve_body( $response ), true ); + + // Pass the full response envelope to Cloud_Snippets, which reads the `data`/`snippets`, + // `meta` and `available_filters` keys. Passing only the unpacked `data` list (as before) + // dropped the metadata, so the result normalised to an empty set. + if ( ! is_array( $json ) || ! isset( $json['data'] ) ) { + return new Cloud_Snippets(); + } - $results = new Cloud_Snippets( $raw ); + $results = new Cloud_Snippets( $json ); $results->page = $page; return $results; 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 3afdbfcdd..f7c7c1730 100644 --- a/src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php +++ b/src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php @@ -218,6 +218,11 @@ 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() ); } diff --git a/tests/phpunit/test-cloud-api-featured.php b/tests/phpunit/test-cloud-api-featured.php index 1bde6ed81..86ab8f9b7 100644 --- a/tests/phpunit/test-cloud-api-featured.php +++ b/tests/phpunit/test-cloud-api-featured.php @@ -68,7 +68,11 @@ private function transient_key( int $page = 1, int $per_page = 10, array $filter $hash = md5( false === $encoded ? '' : $encoded ); $version = get_transient( 'cs_featured_cache_version' ); if ( ! $version ) { + // Mirror the production helper by persisting the freshly generated version, so that + // repeated calls within a test resolve to the same value rather than a new + // timestamp each time (which made cache-key comparisons non-deterministic). $version = (string) ( microtime( true ) * 1000 ); + set_transient( 'cs_featured_cache_version', $version, MONTH_IN_SECONDS ); } return "cs_featured_snippets_v{$version}_p{$page}_pp{$per_page}_{$hash}"; } diff --git a/tests/phpunit/test-cloud-api-search.php b/tests/phpunit/test-cloud-api-search.php new file mode 100644 index 000000000..f3c7759a6 --- /dev/null +++ b/tests/phpunit/test-cloud-api-search.php @@ -0,0 +1,269 @@ +http_request_count = 0; + $this->last_url = null; + $this->mock_response = null; + + add_filter( 'pre_http_request', [ $this, 'mock_search_request' ], 10, 3 ); + } + + /** + * Tear down after each test. + * + * @return void + */ + public function tear_down() { + remove_filter( 'pre_http_request', [ $this, 'mock_search_request' ], 10 ); + + parent::tear_down(); + } + + /** + * Build a successful mock search response in the cloud API envelope format. + * + * @param int $count Number of snippets to include on the page. + * @param int $total Total number of matching snippets. + * + * @return array + */ + private function build_response( int $count = 2, int $total = 42 ): array { + $snippets = []; + + for ( $i = 1; $i <= $count; $i++ ) { + $snippets[] = [ + 'id' => $i, + 'name' => 'Result ' . $i, + ]; + } + + $body = [ + 'data' => $snippets, + 'meta' => [ + 'total' => $total, + 'total_pages' => 5, + 'page' => 0, + ], + 'available_filters' => [], + 'success' => true, + ]; + + return [ + 'headers' => [], + 'body' => wp_json_encode( $body ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'cookies' => [], + ]; + } + + /** + * Intercept outbound HTTP requests to the search endpoint. + * + * @param mixed $preempt Existing preempted value. + * @param array $parsed_args Parsed HTTP request arguments. + * @param string $url Requested URL. + * + * @return mixed + */ + public function mock_search_request( $preempt, array $parsed_args, string $url ) { + if ( false === strpos( $url, 'public/search' ) ) { + return $preempt; + } + + $this->http_request_count += 1; + $this->last_url = $url; + + return null !== $this->mock_response ? $this->mock_response : $this->build_response(); + } + + /** + * Parse the query arguments from a request URL. + * + * @param string|null $url Request URL. + * + * @return array + */ + private function query_args( ?string $url ): array { + $args = []; + wp_parse_str( (string) wp_parse_url( (string) $url, PHP_URL_QUERY ), $args ); + + return $args; + } + + /** + * A successful response is parsed into snippets and a total count. + * + * @return void + */ + public function test_returns_parsed_snippets_and_total(): void { + $result = Cloud_API::fetch_search_results( 'term', 'woo' ); + + $this->assertInstanceOf( Cloud_Snippets::class, $result ); + $this->assertSame( 1, $this->http_request_count ); + $this->assertCount( 2, $result->snippets ); + $this->assertSame( 42, $result->total_snippets ); + } + + /** + * The search request carries the expected query arguments. + * + * @return void + */ + public function test_request_includes_expected_query_args(): void { + Cloud_API::fetch_search_results( 'term', 'woo', 2, 10 ); + $args = $this->query_args( $this->last_url ); + + $this->assertSame( 'term', $args['s_method'] ); + $this->assertSame( 'woo', $args['s'] ); + $this->assertSame( '10', $args['per_page'] ); + $this->assertSame( '1', $args['page'], 'Page should be sent as a zero-based offset.' ); + $this->assertArrayHasKey( 'site_host', $args ); + } + + /** + * The per-page value is capped at the maximum allowed by the API. + * + * @return void + */ + public function test_per_page_is_capped_at_maximum(): void { + Cloud_API::fetch_search_results( 'term', 'woo', 1, 500 ); + $args = $this->query_args( $this->last_url ); + + $this->assertSame( (string) Cloud_API::MAX_RESULTS_PER_PAGE, $args['per_page'] ); + } + + /** + * Non-empty filters are added to the request and empty ones are omitted. + * + * @return void + */ + public function test_filters_are_added_to_request(): void { + Cloud_API::fetch_search_results( + 'term', + 'woo', + 1, + 10, + [ + 'category' => '5', + 'type' => '', + 'status' => '3', + ] + ); + $args = $this->query_args( $this->last_url ); + + $this->assertSame( '5', $args['category'] ); + $this->assertSame( '3', $args['status'] ); + $this->assertArrayNotHasKey( 'type', $args, 'Empty filter values should be omitted from the request.' ); + } + + /** + * A transport error returns an empty result rather than failing. + * + * @return void + */ + public function test_returns_empty_on_http_error(): void { + $this->mock_response = new WP_Error( 'http_request_failed', 'Operation timed out' ); + + $result = Cloud_API::fetch_search_results( 'term', 'woo' ); + + $this->assertCount( 0, $result->snippets ); + $this->assertSame( 0, $result->total_snippets ); + } + + /** + * An invalid JSON body returns an empty result. + * + * @return void + */ + public function test_returns_empty_on_invalid_json(): void { + $this->mock_response = [ + 'headers' => [], + 'body' => 'not-json', + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'cookies' => [], + ]; + + $result = Cloud_API::fetch_search_results( 'term', 'woo' ); + + $this->assertCount( 0, $result->snippets ); + } + + /** + * A response missing the `data` key returns an empty result. + * + * @return void + */ + public function test_returns_empty_on_missing_data_key(): void { + $this->mock_response = [ + 'headers' => [], + 'body' => wp_json_encode( [ 'success' => true ] ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'cookies' => [], + ]; + + $result = Cloud_API::fetch_search_results( 'term', 'woo' ); + + $this->assertCount( 0, $result->snippets ); + } + + /** + * The result is tagged with the requested page number. + * + * @return void + */ + public function test_result_page_matches_requested_page(): void { + $result = Cloud_API::fetch_search_results( 'term', 'woo', 3 ); + + $this->assertSame( 3, $result->page ); + } +} diff --git a/tests/phpunit/test-cloud-snippets.php b/tests/phpunit/test-cloud-snippets.php new file mode 100644 index 000000000..9a7081f7d --- /dev/null +++ b/tests/phpunit/test-cloud-snippets.php @@ -0,0 +1,178 @@ + [ + [ + 'id' => 1, + 'name' => 'First', + ], + [ + 'id' => 2, + 'name' => 'Second', + ], + ], + 'meta' => [ + 'total' => 42, + 'total_pages' => 5, + 'page' => 3, + ], + 'available_filters' => [ + 'types' => [ + [ + 'id' => 2, + 'name' => 'PHP', + ], + ], + ], + 'cloud_id_rev' => [ '1' => 7 ], + ] + ); + + $this->assertCount( 2, $result->snippets ); + $this->assertContainsOnlyInstancesOf( Cloud_Snippet::class, $result->snippets ); + $this->assertSame( 42, $result->total_snippets ); + $this->assertSame( 5, $result->total_pages ); + $this->assertSame( 2, $result->page, 'Page should be stored as a zero-based offset of the API page.' ); + $this->assertNotEmpty( $result->available_filters ); + $this->assertSame( [ '1' => 7 ], $result->cloud_id_rev ); + } + + /** + * The `snippets` key is used when no `data` key is present. + * + * @return void + */ + public function test_falls_back_to_snippets_key(): void { + $result = new Cloud_Snippets( + [ + 'snippets' => [ + [ + 'id' => 1, + 'name' => 'Only', + ], + ], + 'meta' => [ 'total' => 1 ], + ] + ); + + $this->assertCount( 1, $result->snippets ); + $this->assertSame( 1, $result->total_snippets ); + } + + /** + * The `data` key takes precedence over the `snippets` key when both are present. + * + * @return void + */ + public function test_data_key_takes_precedence_over_snippets_key(): void { + $result = new Cloud_Snippets( + [ + 'data' => [ + [ + 'id' => 1, + 'name' => 'A', + ], + [ + 'id' => 2, + 'name' => 'B', + ], + ], + 'snippets' => [ + [ + 'id' => 9, + 'name' => 'Ignored', + ], + ], + 'meta' => [ 'total' => 2 ], + ] + ); + + $this->assertCount( 2, $result->snippets ); + } + + /** + * A bare list of snippets, without the response envelope, yields no snippets. + * + * Guards against regressing to passing the unpacked `data` array straight into the model: + * such an array has no `data`/`snippets`/`meta` keys and normalises to an empty result, + * which is what made cloud search return nothing. + * + * @return void + */ + public function test_bare_list_without_envelope_yields_no_snippets(): void { + $result = new Cloud_Snippets( + [ + [ + 'id' => 1, + 'name' => 'First', + ], + [ + 'id' => 2, + 'name' => 'Second', + ], + ] + ); + + $this->assertCount( 0, $result->snippets ); + $this->assertSame( 0, $result->total_snippets ); + } + + /** + * Null and empty payloads fall back to the default values. + * + * @return void + */ + public function test_empty_input_uses_defaults(): void { + foreach ( [ null, [] ] as $input ) { + $result = new Cloud_Snippets( $input ); + + $this->assertCount( 0, $result->snippets ); + $this->assertSame( 0, $result->total_snippets ); + $this->assertSame( 0, $result->total_pages ); + $this->assertSame( 0, $result->page ); + $this->assertSame( [], $result->available_filters ); + } + } + + /** + * Snippet data without a `meta` block keeps the default totals and filters. + * + * @return void + */ + public function test_missing_meta_keeps_default_totals(): void { + $result = new Cloud_Snippets( + [ + 'data' => [ + [ + 'id' => 1, + 'name' => 'Lonely', + ], + ], + ] + ); + + $this->assertCount( 1, $result->snippets ); + $this->assertSame( 0, $result->total_snippets ); + $this->assertSame( 0, $result->page ); + $this->assertSame( [], $result->available_filters ); + } +}