From 7887f7d9cec8ad2705390119b05ef64f24b6b0ab Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 24 Aug 2026 11:05:16 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8feature:=20Add=20reset-cache=20action?= =?UTF-8?q?=20to=20settings=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'Reset cache' button to the Podcast RSS Import settings page that clears the SimplePie feed-cache rows (both the cached XML and the modification timestamp) plus the importer's per-feed import lock for the configured feed. Multisite site-transient variants are also cleared. Use this when the importer is skipping items or refusing to re-fetch a feed that has changed at the source. The next 'Import now' click will re-pull the XML from the network. clear_feed_cache() is exposed as a public method so it is unit-testable, and a do_action hook (rss2cpt_cache_cleared) lets other code react when the cache is cleared. Two smoke tests pin down the expected option and site-transient names, and a third ensures an empty feed URL is a no-op. --- src/Admin/Settings.php | 103 +++++++++++++++++++++++++++++++++++++++++ tests/SmokeTest.php | 42 +++++++++++++++++ tests/bootstrap.php | 43 +++++++++++++++++ 3 files changed, 188 insertions(+) diff --git a/src/Admin/Settings.php b/src/Admin/Settings.php index ce1c0f8..10f5c68 100644 --- a/src/Admin/Settings.php +++ b/src/Admin/Settings.php @@ -31,9 +31,15 @@ final class Settings { /** Manual import admin-post action. */ public const MANUAL_IMPORT_ACTION = 'rss2cpt_manual_import'; + /** Cache-reset admin-post action. */ + public const RESET_CACHE_ACTION = 'rss2cpt_reset_cache'; + /** Manual import nonce action. */ private const NONCE_ACTION = 'rss2cpt_run_manual_import'; + /** Cache-reset nonce action. */ + private const RESET_NONCE_ACTION = 'rss2cpt_reset_cache'; + /** * Import service. * @@ -71,6 +77,7 @@ final class Settings { add_action( 'admin_menu', array( $this, 'register_page' ) ); add_action( 'admin_init', array( $this, 'register_settings' ) ); add_action( 'admin_post_' . self::MANUAL_IMPORT_ACTION, array( $this, 'handle_manual_import' ) ); + add_action( 'admin_post_' . self::RESET_CACHE_ACTION, array( $this, 'handle_reset_cache' ) ); add_action( 'admin_notices', array( $this, 'render_notices' ) ); $this->scheduler->ensure_scheduled(); } @@ -233,6 +240,14 @@ final class Settings { + +

+

+
+ + + +
redirect_with_notice( 'success', $counts ); } + /** + * Handle a verified cache-reset request. + * + * @return void + */ + public function handle_reset_cache(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( + esc_html__( 'You are not allowed to clear the podcast feed cache.', 'rss2cpt' ), + esc_html__( 'Forbidden', 'rss2cpt' ), + array( 'response' => 403 ) + ); + } + + check_admin_referer( self::RESET_NONCE_ACTION ); + + $settings = Options::get(); + $feed_url = (string) $settings['feed_url']; + $post_type = (string) $settings['post_type']; + + $cleared = $this->clear_feed_cache( $feed_url, $post_type ); + + $this->redirect_with_notice( $cleared ? 'cache_cleared' : 'cache_clear_failed' ); + } + + /** + * Delete the SimplePie feed-cache rows and the importer's per-feed lock. + * + * WordPress's WP_Feed_Cache_Transient stores the cached feed under + * transients named "feed_" and "feed_mod_". On disk, + * transients surface in the options table as _transient_* and + * _transient_timeout_* rows; on multisite the corresponding + * _site_transient_* rows are used. We delete all four standard variants + * plus the two site-transient variants so the next import always re-fetches. + * + * The importer's lock key is "rss2cpt_lock_". + * + * @param string $feed_url Configured feed URL. + * @param string $post_type Configured destination post type. + * @return bool True when at least one cache row was deleted. + */ + public function clear_feed_cache( string $feed_url, string $post_type ): bool { + if ( '' === $feed_url ) { + return false; + } + + $url_hash = md5( $feed_url ); + $lock_hash = md5( $feed_url . '|' . $post_type ); + + $option_keys = array( + '_transient_feed_' . $url_hash, + '_transient_timeout_feed_' . $url_hash, + '_transient_feed_mod_' . $url_hash, + '_transient_timeout_feed_mod_' . $url_hash, + 'rss2cpt_lock_' . $lock_hash, + ); + + $cleared = false; + foreach ( $option_keys as $key ) { + if ( delete_option( $key ) ) { + $cleared = true; + } + } + + // Multisite: feed cache may live in the site transients table. + foreach ( array( 'feed_' . $url_hash, 'feed_mod_' . $url_hash ) as $transient ) { + if ( delete_site_transient( $transient ) ) { + $cleared = true; + } + } + + /** + * Fires after the plugin clears its feed cache and import lock. + * + * @param string $feed_url Feed URL whose cache was cleared. + * @param string $post_type Destination post type. + */ + do_action( 'rss2cpt_cache_cleared', $feed_url, $post_type ); + + return $cleared; + } + /** * Render a manual-import result notice. * @@ -351,6 +448,12 @@ final class Settings { $failed, $warnings ); + } elseif ( 'cache_cleared' === $notice ) { + $class = 'notice notice-success'; + $message = __( 'Feed cache cleared. The next import will re-fetch the feed from the source.', 'rss2cpt' ); + } elseif ( 'cache_clear_failed' === $notice ) { + $class = 'notice notice-warning'; + $message = __( 'Feed cache could not be cleared. Make sure a feed URL is configured.', 'rss2cpt' ); } ?>

diff --git a/tests/SmokeTest.php b/tests/SmokeTest.php index ac31c52..881ff26 100644 --- a/tests/SmokeTest.php +++ b/tests/SmokeTest.php @@ -7,6 +7,9 @@ use PHPUnit\Framework\TestCase; +require_once dirname( __DIR__ ) . '/src/Scheduler.php'; +require_once dirname( __DIR__ ) . '/src/Admin/Settings.php'; + /** * Verifies project-level invariants without loading WordPress. */ @@ -133,4 +136,43 @@ final class SmokeTest extends TestCase { 'fingerprint' => str_repeat( 'b', 64 ), ); } + + /** + * Verify that clear_feed_cache deletes the SimplePie feed-cache option + * rows, the importer's per-feed lock, and the multisite site-transient + * variants derived from the configured feed URL. + */ + public function test_clear_feed_cache_removes_lock_and_simplepie_transient_keys(): void { + rss2cpt_test_reset_delete_logs(); + + $importer = new RSS2CPT\Import\Importer(); + $settings = new RSS2CPT\Admin\Settings( $importer, new RSS2CPT\Scheduler( $importer ) ); + $cleared = $settings->clear_feed_cache( 'https://example.test/feed.xml', 'podcast_episode' ); + + $this->assertTrue( $cleared ); + $this->assertContains( '_transient_feed_' . md5( 'https://example.test/feed.xml' ), $GLOBALS['rss2cpt_test_deleted_options'] ); + $this->assertContains( '_transient_timeout_feed_' . md5( 'https://example.test/feed.xml' ), $GLOBALS['rss2cpt_test_deleted_options'] ); + $this->assertContains( '_transient_feed_mod_' . md5( 'https://example.test/feed.xml' ), $GLOBALS['rss2cpt_test_deleted_options'] ); + $this->assertContains( '_transient_timeout_feed_mod_' . md5( 'https://example.test/feed.xml' ), $GLOBALS['rss2cpt_test_deleted_options'] ); + $this->assertContains( 'rss2cpt_lock_' . md5( 'https://example.test/feed.xml|podcast_episode' ), $GLOBALS['rss2cpt_test_deleted_options'] ); + $this->assertContains( 'feed_' . md5( 'https://example.test/feed.xml' ), $GLOBALS['rss2cpt_test_deleted_site_transients'] ); + $this->assertContains( 'feed_mod_' . md5( 'https://example.test/feed.xml' ), $GLOBALS['rss2cpt_test_deleted_site_transients'] ); + } + + /** + * Verify that clear_feed_cache refuses to do anything destructive when + * the feed URL is empty (so an unconfigured install cannot wipe someone + * else's transient rows by accident). + */ + public function test_clear_feed_cache_is_noop_when_feed_url_empty(): void { + rss2cpt_test_reset_delete_logs(); + + $importer = new RSS2CPT\Import\Importer(); + $settings = new RSS2CPT\Admin\Settings( $importer, new RSS2CPT\Scheduler( $importer ) ); + $cleared = $settings->clear_feed_cache( '', 'podcast_episode' ); + + $this->assertFalse( $cleared ); + $this->assertSame( array(), $GLOBALS['rss2cpt_test_deleted_options'] ); + $this->assertSame( array(), $GLOBALS['rss2cpt_test_deleted_site_transients'] ); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 663efee..0c2aa6d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -55,5 +55,48 @@ function get_gmt_from_date( $date_string ) { return gmdate( 'Y-m-d H:i:s', $timestamp ); } +/** + * In-memory option store for isolated tests. + * + * @param string $name Option name. + * @return bool Always true; the test asserts via $GLOBALS['rss2cpt_test_deleted_options']. + */ +function delete_option( $name ) { + $GLOBALS['rss2cpt_test_deleted_options'][] = $name; + return true; +} + +/** + * In-memory site-transient store for isolated tests. + * + * @param string $name Transient name. + * @return bool Always true; the test asserts via $GLOBALS['rss2cpt_test_deleted_site_transients']. + */ +function delete_site_transient( $name ) { + $GLOBALS['rss2cpt_test_deleted_site_transients'][] = $name; + return true; +} + +/** + * Reset recorded delete_* call logs between tests. + * + * @return void + */ +function rss2cpt_test_reset_delete_logs() { + $GLOBALS['rss2cpt_test_deleted_options'] = array(); + $GLOBALS['rss2cpt_test_deleted_site_transients'] = array(); +} +rss2cpt_test_reset_delete_logs(); + +/** + * Minimal do_action stub for isolated tests. + * + * @param string $hook Hook name. + * @param mixed ...$args Hook arguments. + */ +function do_action( $hook, ...$args ) { + unset( $hook, $args ); +} + require_once dirname( __DIR__ ) . '/src/Options.php'; require_once dirname( __DIR__ ) . '/src/Import/Importer.php';