diff --git a/admin/section/class-convertkit-admin-section-general.php b/admin/section/class-convertkit-admin-section-general.php index 95d4af4da..6b7b178f3 100644 --- a/admin/section/class-convertkit-admin-section-general.php +++ b/admin/section/class-convertkit-admin-section-general.php @@ -14,15 +14,6 @@ */ class ConvertKit_Admin_Section_General extends ConvertKit_Admin_Section_Base { - /** - * Holds the API instance. - * - * @since 1.9.6 - * - * @var ConvertKit_API_V4 - */ - private $api; - /** * Holds the ConvertKit Account Name. * @@ -147,19 +138,10 @@ private function check_credentials() { exit(); } - // Initialize the API. - $this->api = new ConvertKit_API_V4( - CONVERTKIT_OAUTH_CLIENT_ID, - CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI, - $this->settings->get_access_token(), - $this->settings->get_refresh_token(), - $this->settings->debug_enabled(), - 'settings' - ); - // Get Account Details, which we'll use in account_name_callback(), but also lets us test // whether the API credentials are valid. - $this->account = $this->api->get_account(); + $account = new ConvertKit_Resource_Account(); + $this->account = $account->refresh(); // If the request succeeded, no need to perform further actions. if ( ! is_wp_error( $this->account ) ) { @@ -224,6 +206,7 @@ private function maybe_disconnect() { } // Delete cached resources. + $account = new ConvertKit_Resource_Account(); $creator_network = new ConvertKit_Resource_Creator_Network_Recommendations(); $custom_fields = new ConvertKit_Resource_Custom_Fields(); $forms = new ConvertKit_Resource_Forms(); @@ -232,6 +215,7 @@ private function maybe_disconnect() { $products = new ConvertKit_Resource_Products(); $sequences = new ConvertKit_Resource_Sequences(); $tags = new ConvertKit_Resource_Tags(); + $account->delete(); $creator_network->delete(); $custom_fields->delete(); $forms->delete(); diff --git a/includes/class-convertkit-resource-account.php b/includes/class-convertkit-resource-account.php new file mode 100644 index 000000000..c6de9568e --- /dev/null +++ b/includes/class-convertkit-resource-account.php @@ -0,0 +1,173 @@ +has_access_and_refresh_token() ) { + $this->api = new ConvertKit_API_V4( + CONVERTKIT_OAUTH_CLIENT_ID, + CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI, + $settings->get_access_token(), + $settings->get_refresh_token(), + $settings->debug_enabled(), + $context + ); + } + + // Get last query time and existing resources. + $this->last_queried = get_option( $this->settings_name . '_last_queried' ); + $this->resources = get_option( $this->settings_name ); + + } + + /** + * Fetches the account data from the API, storing them in the options table + * with a last queried timestamp. + * + * If the refresh results in a 401, removes the access and refresh tokens from the settings. + * + * @since 3.4.0 + * + * @return WP_Error|array + */ + public function refresh() { + + // Query API for account details. + $results = $this->api->get_account(); + + // Define and store the last query time now. + // This prevents multiple calls to refresh() when the above returns a 401 error. + $this->last_queried = time(); + update_option( $this->settings_name . '_last_queried', $this->last_queried ); + + // If an error occurred, maybe delete credentials from the Plugin's settings + // if the error is a 401 unauthorized. + if ( is_wp_error( $results ) ) { + convertkit_maybe_delete_credentials( $results, CONVERTKIT_OAUTH_CLIENT_ID ); + return $results; + } + + // Store resources in the options table. + // We don't use WordPress' Transients API (i.e. auto expiring options), because they're prone to being + // flushed by some third party "optimization" Plugins. They're also not guaranteed to remain in the options + // table for the amount of time specified; any expiry is a maximum, not a minimum. + // We don't want to keep querying the ConvertKit API for a list of e.g. forms, tags that rarely change as + // a result of transients not being honored, so storing them as options with a separate, persistent expiry + // value is more reliable here. + update_option( $this->settings_name, $results ); + + // Store resources in class variable. + $this->resources = $results; + + /** + * Perform any actions immediately after the resource has been refreshed. + * + * @since 3.4.0 + * + * @param array $results Resources + */ + do_action( 'convertkit_resource_refreshed_' . $this->type, $results ); + + // Return resources. + return $this->get(); + + } + + /** + * Overrides the parent method to return account data, as there's no sorting required. + * + * @since 3.4.0 + * + * @return array + */ + public function get() { + + return get_option( $this->settings_name ); + + } + + /** + * Returns the cached plan_type string. + * + * @since 3.4.0 + * + * @return bool|string + */ + public function get_plan_type() { + + // Get account details from cache. + $account = $this->get(); + + // If no account details are found, or the plan type is not set, return false. + if ( ! $account || ! isset( $account['account']['plan_type'] ) ) { + return false; + } + + // Return the plan type. + return (string) $account['account']['plan_type']; + + } + + /** + * Returns whether the cached plan is a paid Kit plan. + * + * @since 3.4.0 + * + * @return bool + */ + public function is_paid_plan() { + + // Get the plan type from the account details. + $plan_type = $this->get_plan_type(); + + // If no plan type is found, return false. + if ( ! $plan_type ) { + return false; + } + + // Return true if the plan type is not free, false otherwise. + return $plan_type !== 'free'; + + } + +} diff --git a/tests/Integration/ResourceAccountTest.php b/tests/Integration/ResourceAccountTest.php new file mode 100644 index 000000000..d11475d11 --- /dev/null +++ b/tests/Integration/ResourceAccountTest.php @@ -0,0 +1,240 @@ +settings = new \ConvertKit_Settings(); + update_option( + $this->settings::SETTINGS_NAME, + [ + 'access_token' => $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], + 'refresh_token' => $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'], + ] + ); + + // Initialize the resource class we want to test. + $this->resource = new \ConvertKit_Resource_Account(); + + // Confirm initialization didn't result in an error. + $this->assertNotInstanceOf(\WP_Error::class, $this->resource->resources); + } + + /** + * Performs actions after each test. + * + * @since 3.4.0 + */ + public function tearDown(): void + { + // Delete Credentials and Resources from Plugin's settings. + delete_option($this->settings::SETTINGS_NAME); + delete_option($this->resource->settings_name); + delete_option($this->resource->settings_name . '_last_queried'); + + // Destroy the resource class we tested. + unset($this->resource); + + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * Test that the refresh() function performs as expected. + * + * @since 3.4.0 + */ + public function testRefresh() + { + // Confirm that the data is stored in the options table and includes some expected keys. + $result = $this->resource->refresh(); + $this->assertIsArray($result); + + // Check user array. + $this->assertArrayHasKey('user', $result); + $this->assertArrayHasKey('id', $result['user']); + $this->assertArrayHasKey('email', $result['user']); + + // Check account array. + $this->assertArrayHasKey('account', $result); + $this->assertArrayHasKey('id', $result['account']); + $this->assertArrayHasKey('name', $result['account']); + $this->assertArrayHasKey('plan_type', $result['account']); + $this->assertArrayHasKey('primary_email_address', $result['account']); + $this->assertArrayHasKey('created_at', $result['account']); + $this->assertArrayHasKey('plan', $result['account']); + + // Check account plan array. + $this->assertArrayHasKey('plan_type', $result['account']['plan']); + $this->assertArrayHasKey('interval', $result['account']['plan']); + $this->assertArrayHasKey('subscriber_limit', $result['account']['plan']); + $this->assertArrayHasKey('on_trial', $result['account']['plan']); + $this->assertArrayHasKey('trial_lapse_date', $result['account']['plan']); + $this->assertArrayHasKey('renews_at', $result['account']['plan']); + $this->assertArrayHasKey('cancels_at', $result['account']['plan']); + + // Call resource class' get() function. + $result = $this->resource->get(); + + // Check user array. + $this->assertArrayHasKey('user', $result); + $this->assertArrayHasKey('id', $result['user']); + $this->assertArrayHasKey('email', $result['user']); + + // Check account array. + $this->assertArrayHasKey('account', $result); + $this->assertArrayHasKey('id', $result['account']); + $this->assertArrayHasKey('name', $result['account']); + $this->assertArrayHasKey('plan_type', $result['account']); + $this->assertArrayHasKey('primary_email_address', $result['account']); + $this->assertArrayHasKey('created_at', $result['account']); + $this->assertArrayHasKey('plan', $result['account']); + + // Check account plan array. + $this->assertArrayHasKey('plan_type', $result['account']['plan']); + $this->assertArrayHasKey('interval', $result['account']['plan']); + $this->assertArrayHasKey('subscriber_limit', $result['account']['plan']); + $this->assertArrayHasKey('on_trial', $result['account']['plan']); + $this->assertArrayHasKey('trial_lapse_date', $result['account']['plan']); + $this->assertArrayHasKey('renews_at', $result['account']['plan']); + $this->assertArrayHasKey('cancels_at', $result['account']['plan']); + } + + /** + * Test that get() returns false when no cache has been written yet. + * + * @since 3.4.0 + */ + public function testGetReturnsFalseWhenNoCache() + { + $this->assertSame(false, $this->resource->get()); + } + + /** + * Test that get_plan_type() returns null when no cache has been written yet. + * + * @since 3.4.0 + */ + public function testGetPlanTypeReturnsFalseWhenNoCache() + { + $this->assertSame(false, $this->resource->get_plan_type()); + } + + /** + * Test that is_paid_plan() fails closed when no cache has been written yet. + * + * @since 3.4.0 + */ + public function testIsPaidPlanFailsClosedWhenNoCache() + { + $this->assertSame(false, $this->resource->is_paid_plan()); + } + + /** + * Test that is_paid_plan() returns true when the cached plan_type is not free. + * + * @since 3.4.0 + */ + public function testIsPaidPlanTrueForPaidPlan() + { + update_option( + $this->resource->settings_name, + [ + 'account' => [ + 'plan_type' => 'creator_pro', + ], + ] + ); + + $this->assertSame('creator_pro', $this->resource->get_plan_type()); + $this->assertSame(true, $this->resource->is_paid_plan()); + } + + /** + * Test that is_paid_plan() returns false when the cached plan_type is free. + * + * @since 3.4.0 + */ + public function testIsPaidPlanFalseForFreePlan() + { + update_option( + $this->resource->settings_name, + [ + 'account' => [ + 'plan_type' => 'free', + ], + ] + ); + + $this->assertSame('free', $this->resource->get_plan_type()); + $this->assertSame(false, $this->resource->is_paid_plan()); + } + + /** + * Test that is_paid_plan() treats an unrecognised plan_type as paid, so + * newly-introduced paid Kit plans don't lock creators out until we ship + * an update. + * + * @since 3.4.0 + */ + public function testIsPaidPlanTrueForUnknownPlan() + { + update_option( + $this->resource->settings_name, + [ + 'account' => [ + 'plan_type' => 'some_new_paid_plan', + ], + ] + ); + + $this->assertSame(true, $this->resource->is_paid_plan()); + } +} diff --git a/wp-convertkit.php b/wp-convertkit.php index 47964ca66..22f7cd9e6 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -69,6 +69,7 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-cloudflare-turnstile.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-recaptcha.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-spam-protection.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-resource-account.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-resource-creator-network-recommendations.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-restrict-content-cache.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-resource-custom-fields.php';