diff --git a/src/api/class-client.php b/src/api/class-client.php index 0f697f3d..c82dabfc 100644 --- a/src/api/class-client.php +++ b/src/api/class-client.php @@ -60,54 +60,30 @@ class Client { /** * Default timeout, in seconds, for a BeyondWords API request. * - * The create/update content calls can be slow, so this is deliberately - * generous. On WordPress VIP those calls are deferred to background cron - * (see `\BeyondWords\Post\Sync::is_async_generation_enabled()`), so the long - * timeout never blocks a web request there. + * VIP's approved ceiling for a blocking remote request — the + * `WordPressVIPMinimum.Performance.RemoteRequestTimeout` sniff errors above + * 3 seconds. The API answers fast on every endpoint except voices: content + * create/update returns the content ID immediately and generates the audio + * asynchronously server-side, so even the write paths have no reason to + * wait longer than this. * * @since 7.0.0 */ - const REQUEST_TIMEOUT = 30; + const DEFAULT_REQUEST_TIMEOUT = 3; /** - * Timeout, in seconds, for a DELETE request. + * Timeout, in seconds, for the voices GET — the one slow endpoint. * - * Deletion runs on the synchronous trash/delete admin path — once per post - * on a bulk trash off-VIP — so a long blocking timeout would hang the request - * and can breach VIP request limits. Deletes are best-effort (a timeout just - * leaves the audio in the BeyondWords dashboard), so we cap the wait short. + * Voices is genuinely slow to prepare — ~3.7s p95 in Sentry, against + * ~250ms p95 for every other GET — so the shared `DEFAULT_REQUEST_TIMEOUT` + * would abandon more than 5% of cold-cache fetches. Because a failure is + * then negative-cached, that would blank the Voice dropdown for a whole + * `CACHE_TTL_ON_ERROR` rather than just the one render. Only ever paid on + * a cache miss. * * @since 7.0.0 */ - const DELETE_TIMEOUT = 3; - - /** - * Timeout, in seconds, for a cached render-path GET (editor dropdowns). - * - * These run inline on every classic-editor metabox render, so a long - * blocking timeout would tie up a PHP-FPM worker for the length of an admin - * page load. Short, per VIP guidance (see `vip_safe_wp_remote_get`), and - * paired with negative caching ({@see CACHE_TTL_ON_ERROR}) to bound repeat - * failures. - * - * @since 7.0.0 - */ - const RENDER_TIMEOUT = 3; - - /** - * Timeout, in seconds, for the voices GET. - * - * Voices is the one editor dropdown that is genuinely slow to prepare — - * ~3.7s p95 in Sentry, against ~250ms p95 for every other editor GET — so - * the shared `RENDER_TIMEOUT` would abandon more than 5% of cold-cache - * fetches. Because a failure is then negative-cached, that would blank the - * Voice dropdown for a whole `CACHE_TTL_ON_ERROR` rather than just the one - * render. Still well under the generous `REQUEST_TIMEOUT` default, and only - * ever paid on a cache miss. - * - * @since 7.0.0 - */ - const VOICES_TIMEOUT = 8; + const VOICES_REQUEST_TIMEOUT = 8; /** * Register WordPress hooks. @@ -262,9 +238,10 @@ public static function delete_audio( int $post_id ): array|null|false { * * Split out from `delete_audio()` so the deferred trash/delete cron job can * delete audio from the project + content IDs captured before the post meta - * was wiped (see `\BeyondWords\Post\Sync::delete_audio_by_ids()`). Uses the - * short `DELETE_TIMEOUT` because deletion runs on the synchronous - * trash/delete admin path. + * was wiped (see `\BeyondWords\Post\Sync::delete_audio_by_ids()`). Deletion + * runs on the synchronous trash/delete admin path and is best-effort (a + * timeout just leaves the audio in the BeyondWords dashboard), so the short + * default timeout applies. * * @since 7.0.0 * @@ -280,7 +257,7 @@ public static function delete_audio_by_ids( int|string|false $project_id, int|st } $url = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) ); - $response = self::call_api( 'DELETE', $url, '', $post_id, [], self::DELETE_TIMEOUT ); + $response = self::call_api( 'DELETE', $url, '', $post_id ); if ( 204 !== wp_remote_retrieve_response_code( $response ) ) { return false; @@ -393,8 +370,8 @@ public static function get_languages(): array|null|false { /** * GET /organization/voices?filter[language.code]=… * - * Passes the longer `VOICES_TIMEOUT` — this endpoint is materially slower - * than the other editor dropdowns. + * Passes the longer `VOICES_REQUEST_TIMEOUT` — this endpoint is materially + * slower than the other editor dropdowns. * * @param int|string $language_code BeyondWords language code (or numeric ID). * @@ -407,7 +384,7 @@ public static function get_voices( int|string $language_code ): array|null|false rawurlencode( strval( $language_code ) ) ); - return self::cached_get( 'voices_' . $language_code, $url, self::VOICES_TIMEOUT ); + return self::cached_get( 'voices_' . $language_code, $url, self::VOICES_REQUEST_TIMEOUT ); } /** @@ -533,9 +510,9 @@ public static function get_video_settings_templates(): array|null|false { * @param string $body Request body (already JSON-encoded for write methods). * @param int|false $post_id WordPress post ID for error attribution; false to suppress. * @param array $headers Extra per-request headers. - * @param int $timeout Request timeout in seconds. Defaults to REQUEST_TIMEOUT. + * @param int $timeout Request timeout in seconds. Defaults to DEFAULT_REQUEST_TIMEOUT. */ - public static function call_api( string $method, string $url, string $body = '', int|false $post_id = false, array $headers = [], int $timeout = self::REQUEST_TIMEOUT ): array|\WP_Error { + public static function call_api( string $method, string $url, string $body = '', int|false $post_id = false, array $headers = [], int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array|\WP_Error { $post = get_post( $post_id ); self::delete_errors( $post_id ); @@ -570,13 +547,12 @@ public static function call_api( string $method, string $url, string $body = '', * @param string $method HTTP method. * @param string $body Request body. * @param array $headers Extra per-request headers. - * @param int $timeout Request timeout in seconds. Defaults to REQUEST_TIMEOUT; - * DELETEs pass the shorter DELETE_TIMEOUT and cached - * render-path GETs the shorter RENDER_TIMEOUT. + * @param int $timeout Request timeout in seconds. Defaults to DEFAULT_REQUEST_TIMEOUT; + * the voices GET passes the longer VOICES_REQUEST_TIMEOUT. * * @return array */ - private static function build_args( string $method, string $body = '', array $headers = [], int $timeout = self::REQUEST_TIMEOUT ): array { + private static function build_args( string $method, string $body = '', array $headers = [], int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array { return [ 'blocking' => true, 'body' => $body, @@ -614,9 +590,9 @@ private static function cache_key( string $suffix ): string { * (WP_Error / timeout, 5xx, 4xx, non-JSON) are negative-cached for the much * shorter {@see CACHE_TTL_ON_ERROR}, so a slow or unreachable API is probed * at most once per interval instead of re-issuing a blocking request on - * every admin render. The request itself defaults to the short - * {@see RENDER_TIMEOUT} for the same reason; the slower voices endpoint - * passes its own {@see VOICES_TIMEOUT}. + * every admin render. The request itself uses the short + * {@see DEFAULT_REQUEST_TIMEOUT} for the same reason; the slower voices + * endpoint passes its own {@see VOICES_REQUEST_TIMEOUT}. * * A 401 additionally self-heals: call_api() clears * `beyondwords_valid_api_connection`, which ungates the metabox next load. @@ -625,13 +601,13 @@ private static function cache_key( string $suffix ): string { * * @param string $suffix Cache-key suffix (include any project/language id). * @param string $url Absolute endpoint URL. - * @param int $timeout Request timeout in seconds. Defaults to RENDER_TIMEOUT; - * the voices GET passes the longer VOICES_TIMEOUT. + * @param int $timeout Request timeout in seconds. Defaults to DEFAULT_REQUEST_TIMEOUT; + * the voices GET passes the longer VOICES_REQUEST_TIMEOUT. * * @return array|null|false Decoded body on the fetching call; the cached * value ([] after a cached failure) thereafter. */ - private static function cached_get( string $suffix, string $url, int $timeout = self::RENDER_TIMEOUT ): array|null|false { + private static function cached_get( string $suffix, string $url, int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array|null|false { $key = self::cache_key( $suffix ); $cached = get_transient( $key ); diff --git a/src/post/class-sync.php b/src/post/class-sync.php index 4648fe56..3c977e0e 100644 --- a/src/post/class-sync.php +++ b/src/post/class-sync.php @@ -605,7 +605,7 @@ private static function unschedule_audio_generation( int $post_id ): void { * one remote DELETE per post. We read the IDs before the caller wipes the meta * (on trash) or WordPress deletes the row (on permanent delete). Off-VIP, * where WP-Cron is unreliable, we fall back to a synchronous DELETE, bounded - * by the client's short `DELETE_TIMEOUT`. + * by the client's short `DEFAULT_REQUEST_TIMEOUT`. * * The caller must have confirmed `Meta::has_content()` first. * diff --git a/src/settings/class-utils.php b/src/settings/class-utils.php index 2845e492..3131e6eb 100644 --- a/src/settings/class-utils.php +++ b/src/settings/class-utils.php @@ -63,12 +63,6 @@ class Utils { */ const CONNECTION_CHECK_TTL = 5 * MINUTE_IN_SECONDS; - /** - * Timeout (seconds) for the connection-check GET. Kept at the VIP-approved - * 3-second ceiling so a slow API can't stall the settings page render. - */ - const CONNECTION_CHECK_TIMEOUT = 3; - /** * Transient that throttles connection re-validation. Its value is a * fingerprint of the credentials last checked, so changing the API key or @@ -165,8 +159,9 @@ public static function has_valid_api_connection(): bool { * per {@see self::CONNECTION_CHECK_TTL}. Changing the API key or project ID * changes the fingerprint, so saving new credentials re-validates * immediately rather than waiting out the throttle window. - * 2. The request uses a short {@see self::CONNECTION_CHECK_TIMEOUT} timeout so - * a slow API can't block the page render. + * 2. The request uses the client's short default timeout + * ({@see \BeyondWords\Api\Client::DEFAULT_REQUEST_TIMEOUT}) so a slow API + * can't block the page render. * * The stored flag is only cleared on a definitive auth failure (401/403) — * `Client::call_api()` clears it on 401 and we mirror that for 403 here. A @@ -192,7 +187,7 @@ public static function validate_api_connection(): bool { } $url = sprintf( '%s/projects/%d', \BeyondWords\Core\Urls::get_api_url(), $project_id ); - $response = \BeyondWords\Api\Client::call_api( 'GET', $url, '', false, [], self::CONNECTION_CHECK_TIMEOUT ); + $response = \BeyondWords\Api\Client::call_api( 'GET', $url ); // Record the attempt whatever the outcome, so repeated page loads don't // re-issue the request — a down API is throttled just like a healthy one. diff --git a/src/site-health/class-site-health.php b/src/site-health/class-site-health.php index 2eb95837..8a8b0a3f 100644 --- a/src/site-health/class-site-health.php +++ b/src/site-health/class-site-health.php @@ -186,36 +186,88 @@ public static function add_rest_api_connection( array &$info ): void { 'value' => $api_url, ]; + $info['beyondwords']['fields']['api-communication'] = array_merge( + [ 'label' => __( 'Communication with REST API', 'speechkit' ) ], + self::get_api_communication( $api_url ) + ); + } + + /** + * Resolve the "Communication with REST API" value/debug pair. + * + * Deliberately **not** cached. This mirrors WordPress core's own + * `dotorg_communication` field ({@see \WP_Debug_Data::get_wp_core()}), which + * probes wordpress.org on every Info-screen render: Site Health is a + * diagnostic, so a cached result would keep reporting "unreachable" to an + * admin who has just fixed their credentials, DNS or firewall and hit + * refresh to confirm. The request is instead bounded by the shared + * {@see \BeyondWords\Api\Client::DEFAULT_REQUEST_TIMEOUT} and skipped + * entirely when there are no credentials, which keeps the render cost + * predictable without ever serving stale diagnostics. + * + * @param string $api_url BeyondWords REST API base URL. + * + * @return array + */ + private static function get_api_communication( string $api_url ): array { + // Nothing to probe until the site has API credentials — skip the + // blocking request on fresh installs entirely. + if ( ! \BeyondWords\Settings\Utils::has_api_creds() ) { + return [ + 'value' => __( 'Not checked — BeyondWords API credentials are not configured', 'speechkit' ), + 'debug' => 'not-configured', + ]; + } + + // A plain request, not `Client::call_api()`: this is a passive probe, and + // `call_api()` would clear `beyondwords_valid_api_connection` on a 401 as + // a side effect of merely viewing Site Health. `vip_safe_wp_remote_get()` + // is avoided for the same reason — its circuit breaker reports a failure + // without actually retrying, which would make the diagnostic lie. $response = wp_remote_request( $api_url, [ - 'blocking' => true, - 'body' => '', - 'method' => 'GET', + 'method' => 'GET', + 'timeout' => \BeyondWords\Api\Client::DEFAULT_REQUEST_TIMEOUT, ] ); if ( ! is_wp_error( $response ) ) { - $info['beyondwords']['fields']['api-communication'] = [ - 'label' => __( 'Communication with REST API', 'speechkit' ), + return [ 'value' => __( 'BeyondWords API is reachable', 'speechkit' ), 'debug' => 'true', ]; - return; } - $info['beyondwords']['fields']['api-communication'] = [ - 'label' => __( 'Communication with REST API', 'speechkit' ), + return [ 'value' => sprintf( - /* translators: 1: The IP address the REST API resolves to. 2: The error returned by the lookup. */ + /* translators: 1: The IP address the REST API resolves to. 2: The error returned by the request. */ __( 'Unable to reach BeyondWords API at %1$s: %2$s', 'speechkit' ), - gethostbyname( $api_url ), + self::resolve_api_host( $api_url ), $response->get_error_message() ), 'debug' => $response->get_error_message(), ]; } + /** + * Resolve the API host to an IP address for the connectivity diagnostic. + * + * `gethostbyname()` needs a bare hostname, so the URL is parsed first; it + * returns the host (or URL) unchanged when resolution fails. + * + * @param string $api_url BeyondWords REST API base URL. + */ + private static function resolve_api_host( string $api_url ): string { + $host = wp_parse_url( $api_url, PHP_URL_HOST ); + + if ( ! is_string( $host ) || '' === $host ) { + return $api_url; + } + + return gethostbyname( $host ); + } + /** * Add registered + deprecated filter hook info to the debug array. * diff --git a/tests/phpunit/api/test-client.php b/tests/phpunit/api/test-client.php index f1d1fbf0..e33b3f92 100644 --- a/tests/phpunit/api/test-client.php +++ b/tests/phpunit/api/test-client.php @@ -246,8 +246,8 @@ public function delete_audio_by_ids_returns_false_when_ids_missing() * @test * * The deferred trash/delete cron job deletes audio by explicit IDs (the post - * meta is already gone), using the short DELETE_TIMEOUT so it never blocks a - * request for long. + * meta is already gone), using the short DEFAULT_REQUEST_TIMEOUT so it never + * blocks a request for long. */ public function delete_audio_by_ids_issues_delete_with_short_timeout() { @@ -277,7 +277,7 @@ public function delete_audio_by_ids_issues_delete_with_short_timeout() '/projects/' . BEYONDWORDS_TESTS_PROJECT_ID . '/content/' . BEYONDWORDS_TESTS_CONTENT_ID, (string) $captured['url'] ); - $this->assertSame(Client::DELETE_TIMEOUT, $captured['timeout']); + $this->assertSame(Client::DEFAULT_REQUEST_TIMEOUT, $captured['timeout']); delete_option('beyondwords_api_key'); delete_option('beyondwords_project_id'); @@ -542,9 +542,10 @@ public function editor_dropdown_responses_are_cached() * @group settings * * Voices is materially slower to prepare than the other dropdowns (~3.7s p95 - * vs ~250ms), so it gets its own longer bound — the shared RENDER_TIMEOUT - * would abandon cold-cache fetches and, because failures are negative-cached, - * blank the Voice dropdown for a whole CACHE_TTL_ON_ERROR. + * vs ~250ms), so it is the one endpoint with its own longer bound — the + * shared DEFAULT_REQUEST_TIMEOUT would abandon cold-cache fetches and, + * because failures are negative-cached, blank the Voice dropdown for a whole + * CACHE_TTL_ON_ERROR. */ public function voices_get_uses_longer_timeout() { @@ -564,16 +565,11 @@ public function voices_get_uses_longer_timeout() remove_filter('pre_http_request', $filter, 0); - $this->assertSame(Client::VOICES_TIMEOUT, $captured); + $this->assertSame(Client::VOICES_REQUEST_TIMEOUT, $captured); $this->assertGreaterThan( - Client::RENDER_TIMEOUT, - Client::VOICES_TIMEOUT, - 'Voices needs a longer bound than the other editor GETs' - ); - $this->assertLessThan( - Client::REQUEST_TIMEOUT, - Client::VOICES_TIMEOUT, - 'Voices still runs during page generation, so it must stay well under the default' + Client::DEFAULT_REQUEST_TIMEOUT, + Client::VOICES_REQUEST_TIMEOUT, + 'Voices needs a longer bound than every other request' ); delete_option('beyondwords_api_key'); @@ -615,12 +611,14 @@ public function failed_responses_are_negative_cached() * @test * @group settings * - * Cached editor-dropdown GETs render on the admin edit screen, so they use - * a short (<= 3s) timeout to avoid tying up a PHP worker when the API is - * slow. Write-path (and other direct call_api) requests keep the longer - * timeout so user-initiated saves aren't dropped mid-generation. + * Every request — cached editor-dropdown GETs and direct call_api requests + * alike — uses the short default timeout: the API responds quickly on every + * endpoint (content writes return the content ID immediately; audio + * generation is asynchronous server-side), so no request may tie up a PHP + * worker beyond VIP's 3-second ceiling. Voices, the one slow endpoint, is + * covered by voices_get_uses_longer_timeout(). */ - public function render_path_gets_use_a_short_timeout() + public function all_requests_use_the_short_default_timeout() { update_option('beyondwords_api_key', BEYONDWORDS_TESTS_API_KEY); update_option('beyondwords_project_id', BEYONDWORDS_TESTS_PROJECT_ID); @@ -632,25 +630,25 @@ public function render_path_gets_use_a_short_timeout() }; add_filter('pre_http_request', $capture, 0, 3); - Client::get_summarization_settings_templates(); // cached_get → short timeout - Client::get_content(BEYONDWORDS_TESTS_CONTENT_ID); // call_api → long timeout + Client::get_summarization_settings_templates(); // cached_get + Client::get_content(BEYONDWORDS_TESTS_CONTENT_ID); // direct call_api remove_filter('pre_http_request', $capture, 0); $render_timeout = null; - $write_timeout = null; + $direct_timeout = null; foreach ($timeouts as $url => $timeout) { if (str_contains($url, '/summarization_settings_templates')) { $render_timeout = $timeout; } elseif (str_contains($url, '/content/')) { - $write_timeout = $timeout; + $direct_timeout = $timeout; } } $this->assertNotNull($render_timeout, 'Expected a render-path request to be captured'); - $this->assertSame(Client::RENDER_TIMEOUT, $render_timeout, 'Render-path GETs should use the short RENDER_TIMEOUT'); - $this->assertLessThanOrEqual(3, $render_timeout, 'RENDER_TIMEOUT must stay within VIP guidance (<= 3s)'); - $this->assertSame(Client::REQUEST_TIMEOUT, $write_timeout, 'Direct/write-path requests keep the longer default timeout'); + $this->assertSame(Client::DEFAULT_REQUEST_TIMEOUT, $render_timeout, 'Render-path GETs use the short default timeout'); + $this->assertSame(Client::DEFAULT_REQUEST_TIMEOUT, $direct_timeout, 'Direct call_api requests share the same short default'); + $this->assertLessThanOrEqual(3, Client::DEFAULT_REQUEST_TIMEOUT, 'DEFAULT_REQUEST_TIMEOUT must stay within VIP guidance (<= 3s)'); delete_option('beyondwords_api_key'); delete_option('beyondwords_project_id'); diff --git a/tests/phpunit/post/test-sync.php b/tests/phpunit/post/test-sync.php index e4b380a9..e77ecb9d 100644 --- a/tests/phpunit/post/test-sync.php +++ b/tests/phpunit/post/test-sync.php @@ -1682,7 +1682,7 @@ public function on_trash_post_deletes_synchronously_off_vip() // Off-VIP the DELETE runs inline, bounded by the short delete timeout, and // nothing is queued. $this->assertSame('DELETE', $captured['method']); - $this->assertSame(\BeyondWords\Api\Client::DELETE_TIMEOUT, $captured['timeout']); + $this->assertSame(\BeyondWords\Api\Client::DEFAULT_REQUEST_TIMEOUT, $captured['timeout']); $this->assertFalse(wp_next_scheduled(Sync::DELETE_AUDIO_CRON_HOOK, [$projectId, $contentId])); wp_delete_post($postId, true); @@ -1721,7 +1721,7 @@ public function delete_audio_by_ids_delegates_to_api_client() '/projects/' . BEYONDWORDS_TESTS_PROJECT_ID . '/content/' . BEYONDWORDS_TESTS_CONTENT_ID, (string) $captured['url'] ); - $this->assertSame(\BeyondWords\Api\Client::DELETE_TIMEOUT, $captured['timeout']); + $this->assertSame(\BeyondWords\Api\Client::DEFAULT_REQUEST_TIMEOUT, $captured['timeout']); delete_option('beyondwords_api_key'); delete_option('beyondwords_project_id'); diff --git a/tests/phpunit/site-health/test-site-health.php b/tests/phpunit/site-health/test-site-health.php index 1759aa7d..8b154931 100644 --- a/tests/phpunit/site-health/test-site-health.php +++ b/tests/phpunit/site-health/test-site-health.php @@ -3,6 +3,7 @@ declare(strict_types=1); use BeyondWords\SiteHealth\SiteHealth; +use BeyondWords\Api\Client; use BeyondWords\Core\Urls; class SiteHealthTest extends TestCase @@ -118,9 +119,15 @@ public function add_plugin_version_displays_error() /** * @test + * + * With credentials configured the probe runs and reports the API as + * reachable (the mock returns a non-error response for the API root). */ - public function add_rest_api_connection() + public function add_rest_api_connection_reports_reachable_when_configured() { + update_option('beyondwords_api_key', BEYONDWORDS_TESTS_API_KEY); + update_option('beyondwords_project_id', BEYONDWORDS_TESTS_PROJECT_ID); + $siteHealth = new SiteHealth(); $siteHealth->add_rest_api_connection($this->info); @@ -131,6 +138,144 @@ public function add_rest_api_connection() $this->assertSame('Communication with REST API', $this->info['beyondwords']['fields']['api-communication']['label']); $this->assertSame('BeyondWords API is reachable', $this->info['beyondwords']['fields']['api-communication']['value']); $this->assertSame('true', $this->info['beyondwords']['fields']['api-communication']['debug']); + + delete_option('beyondwords_api_key'); + delete_option('beyondwords_project_id'); + } + + /** + * @test + * + * On an unconfigured install the probe is skipped entirely — no blocking + * HTTP request is made when there are no API credentials. + */ + public function add_rest_api_connection_skips_probe_without_credentials() + { + delete_option('beyondwords_api_key'); + delete_option('beyondwords_project_id'); + + $calls = 0; + $counter = function ($preempt, $args, $url) use (&$calls) { + if (str_starts_with((string) $url, Urls::get_api_url())) { + $calls++; + } + return $preempt; + }; + add_filter('pre_http_request', $counter, 0, 3); + + $siteHealth = new SiteHealth(); + $siteHealth->add_rest_api_connection($this->info); + + remove_filter('pre_http_request', $counter, 0); + + $this->assertSame(0, $calls, 'No HTTP request should be made without API credentials'); + $this->assertSame('not-configured', $this->info['beyondwords']['fields']['api-communication']['debug']); + $this->assertStringContainsString('not configured', $this->info['beyondwords']['fields']['api-communication']['value']); + } + + /** + * @test + * + * The probe is deliberately NOT cached. Site Health is a diagnostic, so + * every render must re-check — a cached result would keep reporting + * "unreachable" to an admin who just fixed the problem and hit refresh. + * This mirrors WordPress core's own dotorg_communication field, which + * probes wordpress.org on every Info-screen render. + */ + public function add_rest_api_connection_is_not_cached() + { + update_option('beyondwords_api_key', BEYONDWORDS_TESTS_API_KEY); + update_option('beyondwords_project_id', BEYONDWORDS_TESTS_PROJECT_ID); + + $calls = 0; + $counter = function ($preempt, $args, $url) use (&$calls) { + if (str_starts_with((string) $url, Urls::get_api_url())) { + $calls++; + } + return $preempt; + }; + add_filter('pre_http_request', $counter, 0, 3); + + $first = []; + $second = []; + + $siteHealth = new SiteHealth(); + $siteHealth->add_rest_api_connection($first); + $siteHealth->add_rest_api_connection($second); + + remove_filter('pre_http_request', $counter, 0); + + $this->assertSame(2, $calls, 'Every render must re-probe so the diagnostic is never stale'); + + delete_option('beyondwords_api_key'); + delete_option('beyondwords_project_id'); + } + + /** + * @test + * + * The probe passes the client's shared default timeout, so a slow API + * cannot stall the Site Health render and the bound stays within VIP + * guidance. + */ + public function add_rest_api_connection_uses_the_shared_default_timeout() + { + update_option('beyondwords_api_key', BEYONDWORDS_TESTS_API_KEY); + update_option('beyondwords_project_id', BEYONDWORDS_TESTS_PROJECT_ID); + + $captured = null; + $filter = function ($preempt, $args, $url) use (&$captured) { + if (str_starts_with((string) $url, Urls::get_api_url())) { + $captured = $args; + } + return $preempt; + }; + add_filter('pre_http_request', $filter, 0, 3); + + $siteHealth = new SiteHealth(); + $siteHealth->add_rest_api_connection($this->info); + + remove_filter('pre_http_request', $filter, 0); + + $this->assertIsArray($captured); + $this->assertSame(Client::DEFAULT_REQUEST_TIMEOUT, $captured['timeout']); + $this->assertLessThanOrEqual(3, $captured['timeout'], 'Must stay within VIP guidance (<= 3s)'); + + delete_option('beyondwords_api_key'); + delete_option('beyondwords_project_id'); + } + + /** + * @test + * + * A transport-level failure is reported as unreachable, surfacing the + * error message returned by the request. + */ + public function add_rest_api_connection_reports_unreachable_on_error() + { + update_option('beyondwords_api_key', BEYONDWORDS_TESTS_API_KEY); + update_option('beyondwords_project_id', BEYONDWORDS_TESTS_PROJECT_ID); + + $filter = function ($preempt, $args, $url) { + if (str_starts_with((string) $url, Urls::get_api_url())) { + return new WP_Error('http_request_failed', 'Connection timed out'); + } + return $preempt; + }; + add_filter('pre_http_request', $filter, 1, 3); + + $siteHealth = new SiteHealth(); + $siteHealth->add_rest_api_connection($this->info); + + remove_filter('pre_http_request', $filter, 1); + + $value = $this->info['beyondwords']['fields']['api-communication']['value']; + $this->assertStringContainsString('Unable to reach BeyondWords API', $value); + $this->assertStringContainsString('Connection timed out', $value); + $this->assertSame('Connection timed out', $this->info['beyondwords']['fields']['api-communication']['debug']); + + delete_option('beyondwords_api_key'); + delete_option('beyondwords_project_id'); } /**