diff --git a/src/Client.php b/src/Client.php index 8bf745f..4ca2ee8 100644 --- a/src/Client.php +++ b/src/Client.php @@ -79,11 +79,24 @@ public function check_license( $license = '' ) { /** * Fetch available updates from the remote API. * + * Uses a short-lived site transient cache to avoid redundant HTTP calls + * when WordPress sets the update_plugins transient multiple times. + * * @since 1.1 * + * @param int $cache_period Cache duration in seconds. Pass 0 to skip caching. * @return array|WP_Error Response data or WP_Error on failure. */ - public function updates() { + public function updates( $cache_period = 600 ) { + if ( $cache_period > 0 ) { + $cache_key = $this->integration->get_updates_cache_key(); + $cached = get_site_transient( $cache_key ); + + if ( false !== $cached ) { + return $cached; + } + } + $args = []; if ( $this->integration->license_enabled ) { $license = $this->integration->get_license_code(); @@ -92,17 +105,36 @@ public function updates() { } } - return $this->request( 'updates', $args ); + $response = $this->request( 'updates', $args ); + + if ( $cache_period > 0 && ! is_wp_error( $response ) ) { + set_site_transient( $cache_key, $response, $cache_period ); + } + + return $response; } /** * Fetch plugin details from the remote API. * + * Uses a short-lived site transient cache to avoid redundant HTTP calls + * from plugins_api and the license admin page. + * * @since 1.1 * + * @param int $cache_period Cache duration in seconds. Pass 0 to skip caching. * @return array|WP_Error Response data or WP_Error on failure. */ - public function details() { + public function details( $cache_period = 600 ) { + if ( $cache_period > 0 ) { + $cache_key = $this->integration->get_details_cache_key(); + $cached = get_site_transient( $cache_key ); + + if ( false !== $cached ) { + return $cached; + } + } + $args = []; if ( $this->integration->license_enabled ) { $license = $this->integration->get_license_code(); @@ -111,7 +143,13 @@ public function details() { } } - return $this->request( 'details', $args ); + $response = $this->request( 'details', $args ); + + if ( $cache_period > 0 && ! is_wp_error( $response ) ) { + set_site_transient( $cache_key, $response, $cache_period ); + } + + return $response; } /** diff --git a/src/Integration.php b/src/Integration.php index d05f462..2e0248b 100644 --- a/src/Integration.php +++ b/src/Integration.php @@ -244,6 +244,28 @@ public function get_license_data_key() { return "{$this->license_name}_data"; } + /** + * Retrieves the transient key for caching updates API responses. + * + * @since 1.3 + * + * @return string + */ + public function get_updates_cache_key() { + return "{$this->license_name}_updates_cache"; + } + + /** + * Retrieves the transient key for caching details API responses. + * + * @since 1.3 + * + * @return string + */ + public function get_details_cache_key() { + return "{$this->license_name}_details_cache"; + } + /** * Get license status. @@ -353,6 +375,8 @@ public function is_license_active() { * @return void */ public function refresh_updates_transient() { + delete_site_transient( $this->get_updates_cache_key() ); + delete_site_transient( $this->get_details_cache_key() ); set_site_transient( 'update_plugins', get_site_transient( 'update_plugins' ) ); } @@ -363,6 +387,9 @@ public function refresh_updates_transient() { * @return void */ public function clear_updates_transient() { + delete_site_transient( $this->get_updates_cache_key() ); + delete_site_transient( $this->get_details_cache_key() ); + $transient = get_site_transient( 'update_plugins' ); // Initialize transient if it doesn't exist diff --git a/src/Updater.php b/src/Updater.php index c22136f..b4b0987 100644 --- a/src/Updater.php +++ b/src/Updater.php @@ -224,9 +224,9 @@ public function upgrader_process_complete( $upgrader, $args ) { include_once ABSPATH . 'wp-admin/includes/plugin.php'; $plugin = get_plugin_data( WP_PLUGIN_DIR . '/' . $this->integration->product_file ); - $this->integration->clear_updates_transient(); - $this->integration->product_version = $plugin['Version']; + + $this->integration->clear_updates_transient(); $this->integration->client->ping(); } } diff --git a/tests/ClientApiRequestTest.php b/tests/ClientApiRequestTest.php index 3452053..db9a531 100644 --- a/tests/ClientApiRequestTest.php +++ b/tests/ClientApiRequestTest.php @@ -7,7 +7,7 @@ class ClientApiRequestTest extends TestCase { /** - * Stub the common WP functions used by Client::request(). + * Stub the common WP functions used by Client::request() and updates() cache. */ private function stub_api_dependencies(): void { Functions\when( 'esc_url' )->returnArg(); @@ -17,6 +17,21 @@ private function stub_api_dependencies(): void { Functions\when( 'add_query_arg' )->alias( function ( $args, $url ) { return $url . '?' . http_build_query( $args ); } ); + Functions\when( 'get_site_transient' )->justReturn( false ); + Functions\when( 'set_site_transient' )->justReturn( true ); + } + + /** + * Stub only the HTTP-layer WP functions (no cache stubs). + */ + private function stub_http_dependencies(): void { + Functions\when( 'esc_url' )->returnArg(); + Functions\when( 'site_url' )->justReturn( 'https://example.com' ); + Functions\when( 'get_locale' )->justReturn( 'en_US' ); + Functions\when( 'get_bloginfo' )->justReturn( '6.4' ); + Functions\when( 'add_query_arg' )->alias( function ( $args, $url ) { + return $url . '?' . http_build_query( $args ); + } ); } /** @test */ @@ -239,4 +254,232 @@ public function details_returns_plugin_details() { $this->assertIsArray( $result ); $this->assertArrayHasKey( 'details', $result ); } + + /** @test */ + public function updates_returns_cached_response_without_api_call() { + $integration = $this->create_integration(); + + $cached_response = $this->load_fixture( 'updates-available.json' ); + + Functions\expect( 'get_site_transient' ) + ->once() + ->with( $integration->get_updates_cache_key() ) + ->andReturn( $cached_response ); + + Functions\expect( 'wp_remote_request' )->never(); + + $result = $integration->client->updates(); + + $this->assertIsArray( $result ); + $this->assertSame( '1.3.0', $result['updates']['new_version'] ); + } + + /** @test */ + public function updates_caches_successful_api_response() { + $integration = $this->create_integration(); + $this->stub_http_dependencies(); + + $fixture = $this->load_fixture_raw( 'updates-available.json' ); + $cache_key = $integration->get_updates_cache_key(); + + $stored = null; + + Functions\expect( 'get_site_transient' ) + ->once() + ->with( $cache_key ) + ->andReturn( false ); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture ); + + Functions\expect( 'set_site_transient' ) + ->once() + ->with( + $cache_key, + \Mockery::on( function ( $value ) use ( &$stored ) { + $stored = $value; + return is_array( $value ) && ! empty( $value['updates'] ); + } ), + 600 + ) + ->andReturn( true ); + + $result = $integration->client->updates(); + + $this->assertIsArray( $result ); + $this->assertNotNull( $stored ); + $this->assertArrayHasKey( 'updates', $stored ); + } + + /** @test */ + public function updates_does_not_cache_wp_error_response() { + $integration = $this->create_integration(); + $this->stub_http_dependencies(); + + $cache_key = $integration->get_updates_cache_key(); + + Functions\expect( 'get_site_transient' ) + ->once() + ->with( $cache_key ) + ->andReturn( false ); + + Functions\expect( 'wp_remote_request' ) + ->once() + ->andReturn( new WP_Error( 'http_error', 'Timeout' ) ); + + Functions\expect( 'set_site_transient' )->never(); + + $result = $integration->client->updates(); + + $this->assertInstanceOf( WP_Error::class, $result ); + } + + /** @test */ + public function updates_skips_cache_when_period_is_zero() { + $integration = $this->create_integration(); + $this->stub_http_dependencies(); + + $fixture = $this->load_fixture_raw( 'updates-available.json' ); + + Functions\expect( 'get_site_transient' )->never(); + Functions\expect( 'set_site_transient' )->never(); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture ); + + $result = $integration->client->updates( 0 ); + + $this->assertIsArray( $result ); + $this->assertSame( '1.3.0', $result['updates']['new_version'] ); + } + + /** @test */ + public function updates_uses_custom_cache_period() { + $integration = $this->create_integration(); + $this->stub_http_dependencies(); + + $fixture = $this->load_fixture_raw( 'updates-available.json' ); + $cache_key = $integration->get_updates_cache_key(); + + Functions\expect( 'get_site_transient' ) + ->once() + ->with( $cache_key ) + ->andReturn( false ); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture ); + + Functions\expect( 'set_site_transient' ) + ->once() + ->with( $cache_key, \Mockery::type( 'array' ), 300 ) + ->andReturn( true ); + + $result = $integration->client->updates( 300 ); + + $this->assertIsArray( $result ); + $this->assertSame( '1.3.0', $result['updates']['new_version'] ); + } + + /** @test */ + public function details_returns_cached_response_without_api_call() { + $integration = $this->create_integration(); + + $cached_response = $this->load_fixture( 'details-success.json' ); + + Functions\expect( 'get_site_transient' ) + ->once() + ->with( $integration->get_details_cache_key() ) + ->andReturn( $cached_response ); + + Functions\expect( 'wp_remote_request' )->never(); + + $result = $integration->client->details(); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'details', $result ); + } + + /** @test */ + public function details_caches_successful_api_response() { + $integration = $this->create_integration(); + $this->stub_http_dependencies(); + + $fixture = $this->load_fixture_raw( 'details-success.json' ); + $cache_key = $integration->get_details_cache_key(); + + $stored = null; + + Functions\expect( 'get_site_transient' ) + ->once() + ->with( $cache_key ) + ->andReturn( false ); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture ); + + Functions\expect( 'set_site_transient' ) + ->once() + ->with( + $cache_key, + \Mockery::on( function ( $value ) use ( &$stored ) { + $stored = $value; + return is_array( $value ) && ! empty( $value['details'] ); + } ), + 600 + ) + ->andReturn( true ); + + $result = $integration->client->details(); + + $this->assertIsArray( $result ); + $this->assertNotNull( $stored ); + $this->assertArrayHasKey( 'details', $stored ); + } + + /** @test */ + public function details_does_not_cache_wp_error_response() { + $integration = $this->create_integration(); + $this->stub_http_dependencies(); + + $cache_key = $integration->get_details_cache_key(); + + Functions\expect( 'get_site_transient' ) + ->once() + ->with( $cache_key ) + ->andReturn( false ); + + Functions\expect( 'wp_remote_request' ) + ->once() + ->andReturn( new WP_Error( 'http_error', 'Timeout' ) ); + + Functions\expect( 'set_site_transient' )->never(); + + $result = $integration->client->details(); + + $this->assertInstanceOf( WP_Error::class, $result ); + } + + /** @test */ + public function details_skips_cache_when_period_is_zero() { + $integration = $this->create_integration(); + $this->stub_http_dependencies(); + + $fixture = $this->load_fixture_raw( 'details-success.json' ); + + Functions\expect( 'get_site_transient' )->never(); + Functions\expect( 'set_site_transient' )->never(); + + Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); + Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 ); + Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture ); + + $result = $integration->client->details( 0 ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'details', $result ); + } } diff --git a/tests/IntegrationTransientTest.php b/tests/IntegrationTransientTest.php index e9bb2b4..8e7a008 100644 --- a/tests/IntegrationTransientTest.php +++ b/tests/IntegrationTransientTest.php @@ -23,6 +23,8 @@ public function moves_plugin_from_response_to_no_update() { $saved = null; + Functions\when( 'delete_site_transient' )->justReturn( true ); + Functions\expect( 'get_site_transient' ) ->once() ->with( 'update_plugins' ) @@ -53,6 +55,8 @@ public function creates_no_update_entry_when_plugin_not_in_response() { $saved = null; + Functions\when( 'delete_site_transient' )->justReturn( true ); + Functions\expect( 'get_site_transient' ) ->once() ->with( 'update_plugins' ) @@ -80,6 +84,8 @@ public function initializes_transient_when_false() { $saved = null; + Functions\when( 'delete_site_transient' )->justReturn( true ); + Functions\expect( 'get_site_transient' ) ->once() ->with( 'update_plugins' ) @@ -110,6 +116,8 @@ public function initializes_no_update_array_when_missing() { $saved = null; + Functions\when( 'delete_site_transient' )->justReturn( true ); + Functions\expect( 'get_site_transient' ) ->once() ->with( 'update_plugins' ) @@ -127,4 +135,66 @@ public function initializes_no_update_array_when_missing() { $this->assertIsArray( $saved->no_update ); $this->assertArrayHasKey( 'my-plugin/my-plugin.php', $saved->no_update ); } + + /** @test */ + public function clear_updates_transient_deletes_caches() { + $integration = $this->create_integration(); + + $transient = new \stdClass(); + $transient->response = []; + $transient->no_update = []; + $transient->checked = []; + + $deleted_keys = []; + + Functions\expect( 'delete_site_transient' ) + ->twice() + ->with( Mockery::on( function ( $key ) use ( &$deleted_keys ) { + $deleted_keys[] = $key; + return true; + } ) ); + + Functions\expect( 'get_site_transient' ) + ->once() + ->with( 'update_plugins' ) + ->andReturn( $transient ); + + Functions\expect( 'set_site_transient' ) + ->once(); + + $integration->clear_updates_transient(); + + $this->assertContains( $integration->get_updates_cache_key(), $deleted_keys ); + $this->assertContains( $integration->get_details_cache_key(), $deleted_keys ); + } + + /** @test */ + public function refresh_updates_transient_deletes_caches() { + $integration = $this->create_integration(); + + $transient = new \stdClass(); + + $deleted_keys = []; + + Functions\expect( 'delete_site_transient' ) + ->twice() + ->with( Mockery::on( function ( $key ) use ( &$deleted_keys ) { + $deleted_keys[] = $key; + return true; + } ) ); + + Functions\expect( 'get_site_transient' ) + ->once() + ->with( 'update_plugins' ) + ->andReturn( $transient ); + + Functions\expect( 'set_site_transient' ) + ->once() + ->with( 'update_plugins', $transient ); + + $integration->refresh_updates_transient(); + + $this->assertContains( $integration->get_updates_cache_key(), $deleted_keys ); + $this->assertContains( $integration->get_details_cache_key(), $deleted_keys ); + } } diff --git a/tests/UpdaterPreSetTransientTest.php b/tests/UpdaterPreSetTransientTest.php index ecad74a..e76abe0 100644 --- a/tests/UpdaterPreSetTransientTest.php +++ b/tests/UpdaterPreSetTransientTest.php @@ -23,6 +23,9 @@ private function stub_api_dependencies(): void { /** * Helper: create an Updater with its Integration and stub an API response. * + * Stubs cache as a miss (get_site_transient returns false) so the API call + * proceeds, and accepts the set_site_transient call to store the cache. + * * @param string|null $fixture_name Fixture file name or null for WP_Error. * @param int $status_code HTTP status code. * @return Updater @@ -31,6 +34,9 @@ private function create_updater_with_api_response( ?string $fixture_name, int $s $integration = $this->create_integration(); $this->stub_api_dependencies(); + Functions\when( 'get_site_transient' )->justReturn( false ); + Functions\when( 'set_site_transient' )->justReturn( true ); + if ( null === $fixture_name ) { Functions\expect( 'wp_remote_request' ) ->once() @@ -102,6 +108,9 @@ public function adds_to_no_update_when_version_is_same() { $integration->product_version = '1.3.0'; // Same as fixture. $this->stub_api_dependencies(); + Functions\when( 'get_site_transient' )->justReturn( false ); + Functions\when( 'set_site_transient' )->justReturn( true ); + $fixture = $this->load_fixture_raw( 'updates-available.json' ); Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); @@ -122,6 +131,9 @@ public function adds_to_no_update_when_version_is_lower() { $integration->product_version = '2.0.0'; // Higher than fixture's 1.3.0. $this->stub_api_dependencies(); + Functions\when( 'get_site_transient' )->justReturn( false ); + Functions\when( 'set_site_transient' )->justReturn( true ); + $fixture = $this->load_fixture_raw( 'updates-available.json' ); Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); @@ -142,6 +154,9 @@ public function moves_existing_response_to_no_update_when_not_newer() { $integration->product_version = '1.3.0'; // Same as fixture. $this->stub_api_dependencies(); + Functions\when( 'get_site_transient' )->justReturn( false ); + Functions\when( 'set_site_transient' )->justReturn( true ); + $fixture = $this->load_fixture_raw( 'updates-available.json' ); Functions\expect( 'wp_remote_request' )->once()->andReturn( [] ); @@ -181,4 +196,5 @@ public function does_nothing_when_updates_key_missing() { $this->assertEmpty( $result->response ); $this->assertEmpty( $result->no_update ); } + } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 793e638..1a390fb 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -15,6 +15,10 @@ define( 'WP_PLUGIN_DIR', ABSPATH . 'wp-content/plugins' ); } +if ( ! defined( 'MINUTE_IN_SECONDS' ) ) { + define( 'MINUTE_IN_SECONDS', 60 ); +} + // Minimal WP_Error stub so source files can reference the class. if ( ! class_exists( 'WP_Error' ) ) { class WP_Error {