Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/php/Client/Cloud_API.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() );
}
Expand Down
4 changes: 4 additions & 0 deletions tests/phpunit/test-cloud-api-featured.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
}
Expand Down
269 changes: 269 additions & 0 deletions tests/phpunit/test-cloud-api-search.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
<?php

namespace Code_Snippets\Tests;

use Code_Snippets\Client\Cloud_API;
use Code_Snippets\Model\Cloud_Snippets;
use WP_Error;

/**
* Tests for Cloud_API::fetch_search_results().
*
* @group cloud
*/
class Cloud_API_Search_Test extends TestCase {

/**
* Number of HTTP requests intercepted during a test.
*
* @var int
*/
private int $http_request_count = 0;

/**
* URL of the most recently intercepted request.
*
* @var string|null
*/
private ?string $last_url = null;

/**
* Response to return from the mock HTTP filter.
*
* @var array|WP_Error|null
*/
private $mock_response = null;

/**
* Set up before each test.
*
* @return void
*/
public function set_up() {
parent::set_up();

$this->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<string, string>
*/
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 );
}
}
Loading
Loading