diff --git a/includes/class-convertkit-cache-plugins.php b/includes/class-convertkit-cache-plugins.php index e777f560f..eb9294d09 100644 --- a/includes/class-convertkit-cache-plugins.php +++ b/includes/class-convertkit-cache-plugins.php @@ -80,6 +80,9 @@ public function __construct() { add_filter( 'convertkit_output_script_footer', array( $this, 'siteground_speed_optimizer_exclude_js_combine' ) ); add_filter( 'convertkit_resource_forms_output_script', array( $this, 'siteground_speed_optimizer_exclude_js_combine' ) ); + // Siteground Speed Optimizer: Set Member Content Pages in "Exclude URLs from Caching". + add_filter( 'sgo_exclude_urls_from_cache', array( $this, 'exclude_restrict_content_pages_from_caching' ) ); + // Rocket LazyLoad: Exclude images from lazy loading. add_filter( 'rocket_lazyload_excluded_src', array( $this, 'exclude_hosts' ) ); @@ -340,4 +343,31 @@ public function exclude_local_js_from_minification( $scripts ) { } + /** + * Exclude Restrict Content Pages from caching when the Siteground Speed Optimizer Plugin is installed, active + * and its "Exclude URLs from Caching" setting is enabled. + * + * @since 3.3.6 + * + * @param array $excluded_urls URLs to exclude from caching. + * @return array + */ + public function exclude_restrict_content_pages_from_caching( $excluded_urls ) { + + // Get the list of relative URLs for posts with restrict_content enabled + // from the Plugin's cache. + $restrict_content_cache = WP_ConvertKit()->get_class( 'restrict_content_cache' ); + if ( ! $restrict_content_cache ) { + return $excluded_urls; + } + + // Cache stores Restrict Content pages as id => relative url pairs. + // Siteground just needs to the relative URLs. + $restrict_content_urls = array_values( $restrict_content_cache->get() ); + + // Merge the list of relative URLs with the list of URLs already excluded from caching. + return array_merge( $excluded_urls, $restrict_content_urls ); + + } + } diff --git a/includes/class-convertkit-restrict-content-cache.php b/includes/class-convertkit-restrict-content-cache.php new file mode 100644 index 000000000..f8efe5eb6 --- /dev/null +++ b/includes/class-convertkit-restrict-content-cache.php @@ -0,0 +1,395 @@ + publish etc). + add_action( 'transition_post_status', array( $this, 'on_post_status_transition' ), 10, 3 ); + + // Post updated: refresh cached URL if the permalink for a cached + // post has changed (e.g. slug edit, parent change). + add_action( 'post_updated', array( $this, 'on_post_updated' ), 10, 3 ); + + // Rebuild the cache when the permalink structure or site URL changes. + add_action( 'update_option_permalink_structure', array( $this, 'on_permalink_change' ) ); + add_action( 'update_option_siteurl', array( $this, 'on_permalink_change' ) ); + add_action( 'update_option_home', array( $this, 'on_permalink_change' ) ); + + } + + /** + * Returns the cache as an associative array of post_id => relative_url. + * + * @since 3.3.6 + * + * @return array Associative array of post_id => relative_url. + */ + public function get() { + + // Read cache from the option. + $cache = get_option( self::OPTION_NAME ); + + // If no cache exists, rebuild it. + if ( $cache === false ) { + $cache = $this->rebuild(); + } + + return is_array( $cache ) ? $cache : array(); + + } + + /** + * Returns post IDs stored in the cache. + * + * @since 3.3.6 + * + * @return array Array of post IDs. + */ + public function get_post_ids() { + + return array_map( 'intval', array_keys( $this->get() ) ); + + } + + /** + * Adds or updates a Post ID and relative URL in the cache with its current relative + * permalink. Only Posts with a `publish` post_status are stored. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @return bool Whether the option was updated. + */ + public function add( $post_id ) { + + // Validate post ID. + $post_id = absint( $post_id ); + if ( ! $post_id ) { + return false; + } + + // If the Post is not published, remove it from the cache. + if ( get_post_status( $post_id ) !== 'publish' ) { + return $this->remove( $post_id ); + } + + // Get the Post's relative permalink. + $permalink = get_permalink( $post_id ); + $relative = wp_make_link_relative( $permalink ); + + // Get the cache. + $cache = $this->get(); + + // Skip write if the Post ID already exists in the cache and the relative permalink is the same. + if ( isset( $cache[ $post_id ] ) && $cache[ $post_id ] === $relative ) { + return false; + } + + // Update the cache. + $cache[ $post_id ] = $relative; + return update_option( self::OPTION_NAME, $cache ); + + } + + /** + * Removes a Post ID and relative URL from the cache. + * + * @since 3.3.6 + * + * @param int $post_id Post ID. + * @return bool + */ + public function remove( $post_id ) { + + // Validate post ID. + $post_id = absint( $post_id ); + if ( ! $post_id ) { + return false; + } + + // Get the cache. + $cache = $this->get(); + + // If the Post ID is not in the cache, don't update anything. + if ( ! array_key_exists( $post_id, $cache ) ) { + return false; + } + + // Remove the Post ID and relative URL from the cache. + unset( $cache[ $post_id ] ); + return update_option( self::OPTION_NAME, $cache ); + + } + + /** + * Rebuilds the cache from scratch by querying the database for every + * post whose Kit meta contains a non-empty restrict_content setting, + * then validating each via ConvertKit_Post. + * + * This is a potentially expensive operation, so it should be avoided if possible. + * + * @since 3.3.6 + * + * @return array Cache as an associative array of post_id => relative_url. + */ + public function rebuild() { + + global $wpdb; + + // Serialized array values for `restrict_content` are of the form + // s:N:"..." where N is the string length. Empty values are s:0:"". + // Matching s:_ (a single digit followed by ":) narrows to values + // with length 1..9 which is enough for form_/tag_/product_ IDs. + $post_ids = $wpdb->get_col( + " + SELECT post_id + FROM {$wpdb->postmeta} + WHERE meta_key = '_wp_convertkit_post_meta' + AND meta_value LIKE '%\"restrict_content\";s:%' + AND meta_value NOT LIKE '%\"restrict_content\";s:0:%' + " + ); + + $cache = array(); + + // Iterate over the post IDs. + foreach ( $post_ids as $post_id ) { + // Validate post ID. + $post_id = absint( $post_id ); + if ( ! $post_id ) { + continue; + } + + // Skip if the Post is not published. + if ( get_post_status( $post_id ) !== 'publish' ) { + continue; + } + + // Check if the Post has restrict_content enabled. + $post_settings = new ConvertKit_Post( $post_id ); + if ( ! $post_settings->restrict_content_enabled() ) { + continue; + } + + // Get the Post's relative permalink. + $permalink = get_permalink( $post_id ); + $cache[ $post_id ] = wp_make_link_relative( $permalink ); + } + + // Update the cache. + update_option( self::OPTION_NAME, $cache ); + + return $cache; + + } + + /** + * Hook: fires when post meta is added or updated. Adds/removes the + * Post ID from the cache based on whether the new value has + * restrict_content enabled. + * + * @since 3.3.6 + * + * @param int $meta_id Meta ID (unused). + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + */ + public function on_meta_change( $meta_id, $post_id, $meta_key, $meta_value ) { + + // Check if the meta key is the ConvertKit post meta key. + if ( $meta_key !== '_wp_convertkit_post_meta' ) { + return; + } + + // WP core passes $meta_value as the unserialized value for these + // hooks. Fall back gracefully if it's a string (some plugins may + // bypass core's unserialize). + if ( is_string( $meta_value ) ) { + $meta_value = maybe_unserialize( $meta_value ); + } + + if ( ! is_array( $meta_value ) || empty( $meta_value['restrict_content'] ) ) { + $this->remove( $post_id ); + return; + } + + $this->add( $post_id ); + + } + + /** + * Hook: fires when post meta is deleted. Removes the Post ID from the + * cache if the Kit meta was deleted. + * + * @since 3.3.6 + * + * @param array $meta_ids Meta IDs (unused). + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value (unused). + */ + public function on_meta_delete( $meta_ids, $post_id, $meta_key, $meta_value ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + + // Check if the meta key is the ConvertKit post meta key. + if ( $meta_key !== '_wp_convertkit_post_meta' ) { + return; + } + + // Remove the Post ID from the cache. + $this->remove( $post_id ); + + } + + /** + * Hook: fires when a Post is deleted or trashed. Removes the Post ID + * from the cache. + * + * @since 3.3.6 + * + * @param int $post_id Post ID. + */ + public function on_post_delete( $post_id ) { + + // Remove the Post ID from the cache. + $this->remove( $post_id ); + + } + + /** + * Hook: fires when a Post is restored from trash. Re-adds the Post ID + * to the cache if the Post has restrict_content enabled. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + */ + public function on_post_untrash( $post_id ) { + + $post_settings = new ConvertKit_Post( $post_id ); + + // If the Post has restrict_content enabled, add it to the cache. + if ( $post_settings->restrict_content_enabled() ) { + // Add the Post ID to the cache. + $this->add( $post_id ); + } + + } + + /** + * Hook: fires on any post status transition. Adds/removes the Post ID + * from the cache depending on whether the Post is transitioning to or + * from `publish`. + * + * @since 3.3.6 + * + * @param string $new_status New post status. + * @param string $old_status Old post status. + * @param WP_Post $post Post object. + */ + public function on_post_status_transition( $new_status, $old_status, $post ) { + + // Skip if the post status is the same. + if ( $new_status === $old_status ) { + return; + } + + // Check if the Post has restrict_content enabled. + $post_settings = new ConvertKit_Post( $post->ID ); + if ( ! $post_settings->restrict_content_enabled() ) { + return; + } + + // Add or remove the Post ID from the cache based on the new status. + if ( $new_status === 'publish' ) { + $this->add( $post->ID ); + } elseif ( $old_status === 'publish' ) { + $this->remove( $post->ID ); + } + + } + + /** + * Hook: fires when a Post is updated. If the Post is in the cache, + * refresh its cached URL in case the permalink changed (e.g. slug edit, + * parent change). + * + * @since 3.3.6 + * + * @param int $post_id Post ID. + * @param WP_Post $post_after Post object after update (unused). + * @param WP_Post $post_before Post object before update (unused). + */ + public function on_post_updated( $post_id, $post_after, $post_before ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + + // Get the cache. + $cache = $this->get(); + + // Skip if the Post ID is not in the cache. + if ( ! array_key_exists( $post_id, $cache ) ) { + return; + } + + // Update the cache. + $this->add( $post_id ); + + } + + /** + * Hook: fires when the permalink structure, site URL or home URL changes. + * Rebuilds the cache so cached relative URLs reflect the new structure. + * + * This is a void wrapper around rebuild() so it can be safely used as an + * action callback (rebuild() returns the rebuilt cache for programmatic + * callers). + * + * @since 3.3.6 + */ + public function on_permalink_change() { + + $this->rebuild(); + + } + +} diff --git a/includes/class-wp-convertkit.php b/includes/class-wp-convertkit.php index 659974839..371ab59d0 100644 --- a/includes/class-wp-convertkit.php +++ b/includes/class-wp-convertkit.php @@ -204,6 +204,7 @@ private function initialize_global() { $this->classes['gutenberg'] = new ConvertKit_Gutenberg(); $this->classes['media_library'] = new ConvertKit_Media_Library(); $this->classes['output_restrict_content'] = new ConvertKit_Output_Restrict_Content(); + $this->classes['restrict_content_cache'] = new ConvertKit_Restrict_Content_Cache(); $this->classes['review_request'] = new ConvertKit_Review_Request( 'Kit', 'convertkit', CONVERTKIT_PLUGIN_PATH ); $this->classes['preview_output'] = new ConvertKit_Preview_Output(); $this->classes['setup'] = new ConvertKit_Setup(); diff --git a/tests/EndToEnd/restrict-content/general/RestrictContentCacheCest.php b/tests/EndToEnd/restrict-content/general/RestrictContentCacheCest.php index 347d3315d..ee8e7a0b4 100644 --- a/tests/EndToEnd/restrict-content/general/RestrictContentCacheCest.php +++ b/tests/EndToEnd/restrict-content/general/RestrictContentCacheCest.php @@ -539,6 +539,96 @@ public function testRestrictContentWPRocketCache(EndToEndTester $I) $I->deactivateThirdPartyPlugin($I, 'disable-_load_textdomain_just_in_time-doing_it_wrong-notice'); } + /** + * Tests that the SiteGround Speed Optimizer Plugin does not interfere with + * Restrict Content output when a ck_subscriber_id cookie is present, and + * that the Restrict Content Page is registered against the + * sgo_exclude_urls_from_cache filter via the Plugin's cache option. + * + * @since 3.3.6 + * + * @param EndToEndTester $I Tester. + */ + public function testRestrictContentSitegroundSpeedOptimizer(EndToEndTester $I) + { + // Activate SiteGround Speed Optimizer Plugin. + $I->activateThirdPartyPlugin($I, 'sg-cachepress'); + + // Enable SG Speed Optimizer's Dynamic Cache so its exclusion logic is active. + $I->haveOptionInDatabase('siteground_optimizer_enable_cache', 1); + + // Configure SG Speed Optimizer's Heartbeat, to prevent the Plugin from + // making requests to the site during the test. + $I->haveOptionInDatabase('siteground_optimizer_heartbeat_post_interval', 120); + $I->haveOptionInDatabase('siteground_optimizer_heartbeat_dashboard_interval', 120); + $I->haveOptionInDatabase('siteground_optimizer_heartbeat_frontend_interval', 120); + + // Confirm the Plugin's Restrict Content cache option is empty, as no + // Restrict Content Page has been configured yet. + $cache = $I->grabOptionFromDatabase('_wp_convertkit_restrict_content_posts'); + $I->assertTrue( empty( $cache ) ); + + // Add a Page using the Gutenberg editor. + $I->addGutenbergPage( + $I, + title: 'Kit: Restrict Content: Product: SG Speed Optimizer' + ); + + // Configure metabox's Restrict Content setting = Product name. + $I->configurePluginSidebarSettings( + $I, + form: 'None', + restrictContent: $_ENV['CONVERTKIT_API_PRODUCT_NAME'] + ); + + // Add blocks. + $I->addGutenbergParagraphBlock($I, 'Visible content.'); + $I->addGutenbergBlock( + $I, + blockName: 'More', + blockProgrammaticName: 'more' + ); + $I->addGutenbergParagraphBlock($I, 'Member-only content.'); + + // Publish Page. + $url = $I->publishGutenbergPage($I); + + // Load the Dashboard. + $I->amOnAdminPage('index.php'); + $I->waitForElementVisible('body.index-php'); + + // Confirm the Plugin's Restrict Content cache option now includes + // the relative URL for this Page. This URL is fed to + // SiteGround Speed Optimizer via the sgo_exclude_urls_from_cache + // filter at runtime, so that SG excludes the Restrict Content Page + // from its cache. + $cache = $I->grabOptionFromDatabase('_wp_convertkit_restrict_content_posts'); + $I->assertIsArray($cache); + $I->assertContains(wp_make_link_relative($url), array_values($cache)); + + // Log out, so that caching is honored. + $I->logOut(); + + // Navigate to the page. + $I->amOnUrl($url); + + // Test that the restricted content CTA displays when no valid signed subscriber ID is used, + // to confirm caching does not show member-only content. + $I->testRestrictContentByProductHidesContentWithCTA($I); + + // Test that the restricted content displays when a valid signed subscriber ID is used, + // to confirm caching does not show the incorrect content. + $I->setRestrictContentCookieAndReload( + $I, + subscriberID: $_ENV['CONVERTKIT_API_SIGNED_SUBSCRIBER_ID'], + urlOrPageID: $url + ); + $I->testRestrictContentDisplaysContent($I); + + // Deactivate SiteGround Speed Optimizer Plugin. + $I->deactivateThirdPartyPlugin($I, 'sg-cachepress'); + } + /** * Deactivate and reset Plugin(s) after each test, if the test passes. * We don't use _after, as this would provide a screenshot of the Plugin diff --git a/tests/Integration/RestrictContentCacheTest.php b/tests/Integration/RestrictContentCacheTest.php new file mode 100644 index 000000000..4b9b3eb82 --- /dev/null +++ b/tests/Integration/RestrictContentCacheTest.php @@ -0,0 +1,693 @@ +cache = \WP_ConvertKit()->get_class('restrict_content_cache'); + if ( ! $this->cache ) { + $this->cache = new \ConvertKit_Restrict_Content_Cache(); + } + } + + /** + * Performs actions after each test. + * + * @since 3.3.6 + */ + public function tearDown(): void + { + delete_option(\ConvertKit_Restrict_Content_Cache::OPTION_NAME); + + parent::tearDown(); + } + + /** + * Test that get() returns an empty array when no restrict content posts exist. + * + * @since 3.3.6 + */ + public function testGetReturnsEmptyArrayWhenNoRestrictContentPostsExist() + { + $this->assertSame( array(), $this->cache->get() ); + } + + /** + * Test that get_post_ids() returns an array of integer post IDs. + * + * @since 3.3.6 + */ + public function testGetPostIdsReturnsIntArray() + { + $post_id_1 = $this->createRestrictContentPost(); + $post_id_2 = $this->createRestrictContentPost(); + + $this->cache->add( $post_id_1 ); + $this->cache->add( $post_id_2 ); + + $ids = $this->cache->get_post_ids(); + + $this->assertCount( 2, $ids ); + $this->assertContains( $post_id_1, $ids ); + $this->assertContains( $post_id_2, $ids ); + foreach ( $ids as $id ) { + $this->assertIsInt( $id ); + } + } + + /** + * Test that add() stores a published Post ID and its relative URL in the cache. + * + * @since 3.3.6 + */ + public function testAddPublishedPostAddsToCache() + { + $post_id = $this->createRestrictContentPost(); + + $this->cache->add( $post_id ); + + $cache = $this->cache->get(); + + $this->assertArrayHasKey( $post_id, $cache ); + $this->assertSame( wp_make_link_relative( get_permalink( $post_id ) ), $cache[ $post_id ] ); + } + + /** + * Test that add() removes a Post from the cache if it is not published. + * + * @since 3.3.6 + */ + public function testAddDraftPostRemovesFromCache() + { + $post_id = $this->createRestrictContentPost(); + $this->cache->add( $post_id ); + $this->assertArrayHasKey( $post_id, $this->cache->get() ); + + // Move the Post to draft. + wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'draft', + ) + ); + + // Direct call to add() should now remove. + $this->cache->add( $post_id ); + + $this->assertArrayNotHasKey( $post_id, $this->cache->get() ); + } + + /** + * Test that add() does not write to the option when the cached URL is unchanged. + * + * @since 3.3.6 + */ + public function testAddDoesNotWriteWhenUnchanged() + { + $post_id = $this->createRestrictContentPost(); + $this->cache->add( $post_id ); + + // Counter for update_option calls to our option. + $writes = 0; + add_action( + 'update_option_' . \ConvertKit_Restrict_Content_Cache::OPTION_NAME, + function () use ( &$writes ) { + $writes++; + } + ); + + // Call add() again with the same state. + $this->cache->add( $post_id ); + + $this->assertSame( 0, $writes, 'update_option was called even though the cache value did not change.' ); + } + + /** + * Test that remove() removes a Post ID from the cache. + * + * @since 3.3.6 + */ + public function testRemoveDeletesFromCache() + { + $post_id_1 = $this->createRestrictContentPost(); + $post_id_2 = $this->createRestrictContentPost(); + $this->cache->add( $post_id_1 ); + $this->cache->add( $post_id_2 ); + + $this->cache->remove( $post_id_1 ); + + $cache = $this->cache->get(); + $this->assertArrayNotHasKey( $post_id_1, $cache ); + $this->assertArrayHasKey( $post_id_2, $cache ); + } + + /** + * Test that remove() does not modify the cache when the Post ID is not present. + * + * @since 3.3.6 + */ + public function testRemoveNoOpWhenPostNotInCache() + { + $post_id = $this->createRestrictContentPost(); + $this->cache->add( $post_id ); + + $before = $this->cache->get(); + + $this->cache->remove( 999999 ); + + $this->assertSame( $before, $this->cache->get() ); + } + + /** + * Test that rebuild() finds all published restrict content posts and ignores + * drafts, non-restrict-content posts, and empty restrict_content values. + * + * @since 3.3.6 + */ + public function testRebuildFromScratchFindsAllPublishedRestrictContentPosts() + { + $published_1 = $this->createRestrictContentPost( 'tag_123', 'publish' ); + $published_2 = $this->createRestrictContentPost( 'tag_456', 'publish' ); + $published_3 = $this->createRestrictContentPost( 'product_789', 'publish' ); + + // Draft with restrict_content — should NOT appear. + $draft = $this->createRestrictContentPost( 'tag_123', 'draft' ); + + // Published Post without restrict_content — should NOT appear. + $non_restrict = $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Non-restrict Content Post', + ) + ); + update_post_meta( + $non_restrict, + '_wp_convertkit_post_meta', + array( + 'form' => '-1', + 'landing_page' => '', + 'tag' => '', + 'restrict_content' => '', + ) + ); + + delete_option( \ConvertKit_Restrict_Content_Cache::OPTION_NAME ); + + $cache = $this->cache->rebuild(); + + $this->assertArrayHasKey( $published_1, $cache ); + $this->assertArrayHasKey( $published_2, $cache ); + $this->assertArrayHasKey( $published_3, $cache ); + $this->assertArrayNotHasKey( $draft, $cache ); + $this->assertArrayNotHasKey( $non_restrict, $cache ); + } + + /** + * Test that rebuild() ignores posts whose restrict_content setting is empty. + * + * @since 3.3.6 + */ + public function testRebuildIgnoresPostsWithEmptyRestrictContentSetting() + { + $post_id = $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Empty restrict_content', + ) + ); + update_post_meta( + $post_id, + '_wp_convertkit_post_meta', + array( + 'form' => '-1', + 'landing_page' => '', + 'tag' => '', + 'restrict_content' => '', + ) + ); + + delete_option( \ConvertKit_Restrict_Content_Cache::OPTION_NAME ); + + $cache = $this->cache->rebuild(); + + $this->assertArrayNotHasKey( $post_id, $cache ); + } + + /** + * Test that rebuild() ignores posts whose restrict_content setting is "0". + * + * @since 3.3.6 + */ + public function testRebuildIgnoresPostsWithRestrictContentSetToZero() + { + $post_id = $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Zero restrict_content', + ) + ); + update_post_meta( + $post_id, + '_wp_convertkit_post_meta', + array( + 'form' => '-1', + 'landing_page' => '', + 'tag' => '', + 'restrict_content' => '0', + ) + ); + + delete_option( \ConvertKit_Restrict_Content_Cache::OPTION_NAME ); + + $cache = $this->cache->rebuild(); + + $this->assertArrayNotHasKey( $post_id, $cache ); + } + + /** + * Test that get() automatically rebuilds the cache when the option is missing. + * + * @since 3.3.6 + */ + public function testGetAutoRebuildsWhenOptionMissing() + { + $post_id = $this->createRestrictContentPost(); + + // Wipe the option so get() has to auto-rebuild. + delete_option( \ConvertKit_Restrict_Content_Cache::OPTION_NAME ); + + $cache = $this->cache->get(); + + $this->assertArrayHasKey( $post_id, $cache ); + } + + /** + * Test that when update_post_meta() sets restrict_content on a Post, the + * added_post_meta hook fires and the Post is added to the cache. + * + * @since 3.3.6 + */ + public function testHookAddedPostMetaAddsToCache() + { + $post_id = $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Added Meta', + ) + ); + + // This should trigger added_post_meta. + update_post_meta( + $post_id, + '_wp_convertkit_post_meta', + array( + 'form' => '-1', + 'landing_page' => '', + 'tag' => '', + 'restrict_content' => 'tag_123', + ) + ); + + $this->assertArrayHasKey( $post_id, $this->cache->get() ); + } + + /** + * Test that when update_post_meta() enables restrict_content on a Post with + * existing Kit meta, the updated_post_meta hook fires and the Post is added + * to the cache. + * + * @since 3.3.6 + */ + public function testHookUpdatedPostMetaAddsToCacheOnEnable() + { + $post_id = $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Enable via update', + ) + ); + + // First save with restrict_content empty. + update_post_meta( + $post_id, + '_wp_convertkit_post_meta', + array( + 'form' => '-1', + 'landing_page' => '', + 'tag' => '', + 'restrict_content' => '', + ) + ); + $this->assertArrayNotHasKey( $post_id, $this->cache->get() ); + + // Enable restrict_content. + update_post_meta( + $post_id, + '_wp_convertkit_post_meta', + array( + 'form' => '-1', + 'landing_page' => '', + 'tag' => '', + 'restrict_content' => 'tag_123', + ) + ); + + $this->assertArrayHasKey( $post_id, $this->cache->get() ); + } + + /** + * Test that when update_post_meta() clears restrict_content on a cached Post, + * the Post is removed from the cache. + * + * @since 3.3.6 + */ + public function testHookUpdatedPostMetaRemovesFromCacheOnDisable() + { + $post_id = $this->createRestrictContentPost(); + $this->assertArrayHasKey( $post_id, $this->cache->get() ); + + // Disable restrict_content. + update_post_meta( + $post_id, + '_wp_convertkit_post_meta', + array( + 'form' => '-1', + 'landing_page' => '', + 'tag' => '', + 'restrict_content' => '', + ) + ); + + $this->assertArrayNotHasKey( $post_id, $this->cache->get() ); + } + + /** + * Test that delete_post_meta() removes the Post from the cache via the + * deleted_post_meta hook. + * + * @since 3.3.6 + */ + public function testHookDeletedPostMetaRemovesFromCache() + { + $post_id = $this->createRestrictContentPost(); + $this->assertArrayHasKey( $post_id, $this->cache->get() ); + + delete_post_meta( $post_id, '_wp_convertkit_post_meta' ); + + $this->assertArrayNotHasKey( $post_id, $this->cache->get() ); + } + + /** + * Test that wp_delete_post() removes the Post from the cache via the + * before_delete_post hook. + * + * @since 3.3.6 + */ + public function testHookDeletePostRemovesFromCache() + { + $post_id = $this->createRestrictContentPost(); + $this->assertArrayHasKey( $post_id, $this->cache->get() ); + + wp_delete_post( $post_id, true ); + + $this->assertArrayNotHasKey( $post_id, $this->cache->get() ); + } + + /** + * Test that wp_trash_post() removes the Post from the cache via the + * wp_trash_post hook. + * + * @since 3.3.6 + */ + public function testHookTrashPostRemovesFromCache() + { + $post_id = $this->createRestrictContentPost(); + $this->assertArrayHasKey( $post_id, $this->cache->get() ); + + wp_trash_post( $post_id ); + + $this->assertArrayNotHasKey( $post_id, $this->cache->get() ); + } + + /** + * Test that wp_untrash_post() re-adds the Post to the cache if it still has + * restrict_content enabled. + * + * @since 3.3.6 + */ + public function testHookUntrashPostReAddsToCacheIfRestrictContentEnabled() + { + $post_id = $this->createRestrictContentPost(); + wp_trash_post( $post_id ); + $this->assertArrayNotHasKey( $post_id, $this->cache->get() ); + + wp_untrash_post( $post_id ); + + // wp_untrash_post restores as 'draft' by default; the untrash hook + // re-adds via ConvertKit_Post::restrict_content_enabled() but add() + // checks post_status === 'publish'. Publish so it gets recorded. + wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'publish', + ) + ); + + $this->assertArrayHasKey( $post_id, $this->cache->get() ); + } + + /** + * Test that transitioning a Post from publish to draft removes it from the + * cache via the transition_post_status hook. + * + * @since 3.3.6 + */ + public function testHookTransitionToDraftRemovesFromCache() + { + $post_id = $this->createRestrictContentPost(); + $this->assertArrayHasKey( $post_id, $this->cache->get() ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'draft', + ) + ); + + $this->assertArrayNotHasKey( $post_id, $this->cache->get() ); + } + + /** + * Test that transitioning a Post from draft to publish adds it to the cache + * via the transition_post_status hook. + * + * @since 3.3.6 + */ + public function testHookTransitionToPublishAddsToCache() + { + $post_id = $this->createRestrictContentPost( 'tag_123', 'draft' ); + $this->assertArrayNotHasKey( $post_id, $this->cache->get() ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'publish', + ) + ); + + $this->assertArrayHasKey( $post_id, $this->cache->get() ); + } + + /** + * Test that updating a Post that is not in the cache does not add it to the cache. + * + * @since 3.3.6 + */ + public function testHookPostUpdatedDoesNotAddPostNotInCache() + { + // Post with no restrict_content. + $post_id = $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Not in cache', + ) + ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => 'Updated title', + ) + ); + + $this->assertArrayNotHasKey( $post_id, $this->cache->get() ); + } + + /** + * Test that private posts are not included in the cache. + * + * @since 3.3.6 + */ + public function testCacheIgnoresPasswordProtectedOrPrivatePosts() + { + $post_id = $this->createRestrictContentPost( 'tag_123', 'private' ); + + // Rebuild explicitly (private posts are not in the LIKE query result set + // through publish filter, but we want to confirm behaviour). + delete_option( \ConvertKit_Restrict_Content_Cache::OPTION_NAME ); + $cache = $this->cache->rebuild(); + + $this->assertArrayNotHasKey( $post_id, $cache ); + } + + /** + * Test the full lifecycle of a Post through the cache: add, trash, untrash, + * publish, delete. + * + * @since 3.3.6 + */ + public function testCacheHandlesTrashedThenUntrashedPostCorrectly() + { + $post_id = $this->createRestrictContentPost(); + $this->assertArrayHasKey( $post_id, $this->cache->get() ); + + // Trash. + wp_trash_post( $post_id ); + $this->assertArrayNotHasKey( $post_id, $this->cache->get() ); + + // Untrash + publish. + wp_untrash_post( $post_id ); + wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'publish', + ) + ); + $this->assertArrayHasKey( $post_id, $this->cache->get() ); + + // Force-delete. + wp_delete_post( $post_id, true ); + $this->assertArrayNotHasKey( $post_id, $this->cache->get() ); + } + + /** + * Test that the cache survives when post meta contains malformed (non-array) data. + * + * @since 3.3.6 + */ + public function testCacheSurvivesPostMetaWithMalformedData() + { + $post_id = $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Malformed meta', + ) + ); + + // Save meta as a string, not an array. + update_post_meta( $post_id, '_wp_convertkit_post_meta', 'not-an-array' ); + + // Should not crash and should not add the post. + $cache = $this->cache->get(); + $this->assertArrayNotHasKey( $post_id, $cache ); + } + + /** + * Test that the SiteGround Speed Optimizer filter integration returns the + * restrict content post URLs. This proves the end-to-end integration: + * ConvertKit_Cache_Plugins::exclude_restrict_content_pages_from_caching() + * consumes the cache and merges the URLs into the excluded URLs list. + * + * @since 3.3.6 + */ + public function testSitegroundExcludeRestrictContentUrlsFilter() + { + $post_id = $this->createRestrictContentPost(); + + $excluded_urls = apply_filters( 'sgo_exclude_urls_from_cache', array() ); + + $this->assertContains( + wp_make_link_relative( get_permalink( $post_id ) ), + $excluded_urls + ); + } + + /** + * Helper: create a Post with Kit meta setting restrict_content to the given value. + * + * @since 3.3.6 + * + * @param string $restrict_content Restrict content value (e.g. tag_123). + * @param string $status Post status (default: publish). + * @return int Post ID. + */ + private function createRestrictContentPost( $restrict_content = 'tag_123', $status = 'publish' ) + { + $post_id = $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => $status, + 'post_title' => 'Restrict Content Test', + ) + ); + + update_post_meta( + $post_id, + '_wp_convertkit_post_meta', + array( + 'form' => '-1', + 'landing_page' => '', + 'tag' => '', + 'restrict_content' => $restrict_content, + ) + ); + + return $post_id; + } +} diff --git a/tests/Support/Helper/KitPlugin.php b/tests/Support/Helper/KitPlugin.php index 93fee63e9..790877f81 100644 --- a/tests/Support/Helper/KitPlugin.php +++ b/tests/Support/Helper/KitPlugin.php @@ -603,6 +603,7 @@ public function resetKitPlugin($I) // Cache. $I->dontHaveOptionInDatabase('convertkit_restrict_content_enabled'); + $I->dontHaveOptionInDatabase('_wp_convertkit_restrict_content_posts'); // Cookies. $I->resetCookie('ck_subscriber_id'); diff --git a/wp-convertkit.php b/wp-convertkit.php index 1af93d377..411154f48 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -68,6 +68,7 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-preview-output.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-recaptcha.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'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-resource-forms.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-resource-landing-pages.php';