diff --git a/readme.txt b/readme.txt index c2b7dc6f..486c9306 100644 --- a/readme.txt +++ b/readme.txt @@ -174,6 +174,9 @@ A: You can upgrade to a paid account by adding your *Payment details* on your [a A: When the conversion feature is enabled (to convert images to AVIF or WebP), each image will use double the number of credits: one for compression and one for format conversion. == Changelog == += 3.7.0 = +* chore: migrated meta key from `tiny_compress_images` to `_tiny_compress_images` + = 3.6.14 = * fix: added check for valid path before deleting converted image * fix: use hook uninstall_plugin instead of uninstall.php to prevent dependency deletion diff --git a/src/class-tiny-migrate.php b/src/class-tiny-migrate.php new file mode 100644 index 00000000..093d68d7 --- /dev/null +++ b/src/class-tiny-migrate.php @@ -0,0 +1,144 @@ + Ordered map of version to migration callable. + */ + private static function migrations() { + return array( + 1 => array( self::class, 'migrate_meta_key_to_private' ), + ); + } + + /** + * Runs all pending migrations in version order. + * + * Compares the stored database version against each known migration + * and executes any that have not yet been applied. Updates the stored + * version upon completion. + * + * @since 3.7.0 + * + * @return void + */ + public static function run() { + $stored_version = (int) get_option( self::DB_VERSION_OPTION, 0 ); + + if ( $stored_version >= self::DB_VERSION ) { + return; + } + + foreach ( self::migrations() as $version => $migration ) { + if ( $stored_version >= $version ) { + continue; + } + + if ( get_transient( self::MIGRATION_BACKOFF_KEY ) ) { + // transient key to hold migrations exists so exit early + return; + } + + if ( ! call_user_func( $migration ) ) { + set_transient( self::MIGRATION_BACKOFF_KEY, 1, HOUR_IN_SECONDS ); + return; + } + } + + update_option( self::DB_VERSION_OPTION, self::DB_VERSION ); + } + + /** + * Migrates the tiny meta key from public to private. + * + * Renames all `tiny_compress_images` post meta entries to + * `_tiny_compress_images`. + * + * @since 3.7.0 + * + * @return bool True on success or when there is nothing to migrate, false on DB error. + */ + private static function migrate_meta_key_to_private() { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->update( + $wpdb->postmeta, + array( 'meta_key' => '_tiny_compress_images' ), + array( 'meta_key' => 'tiny_compress_images' ), + array( '%s' ), + array( '%s' ) + ); + + if ( false === $result ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( 'Tinify: failed to migrate meta key. DB error: ' . $wpdb->last_error ); + return false; + } + + // A return value of 0 means there was nothing to migrate, which is valid + // for fresh installs or databases that were already migrated. + return false !== $result; + } +} diff --git a/src/config/class-tiny-config.php b/src/config/class-tiny-config.php index dfab8f81..6378578c 100644 --- a/src/config/class-tiny-config.php +++ b/src/config/class-tiny-config.php @@ -9,5 +9,5 @@ class Tiny_Config { const SHRINK_URL = 'https://api.tinify.com/shrink'; const KEYS_URL = 'https://api.tinify.com/keys'; const MONTHLY_FREE_COMPRESSIONS = 500; - const META_KEY = 'tiny_compress_images'; + const META_KEY = '_tiny_compress_images'; } diff --git a/test/helpers/wordpress.php b/test/helpers/wordpress.php index 45e2406d..f18543a6 100644 --- a/test/helpers/wordpress.php +++ b/test/helpers/wordpress.php @@ -2,6 +2,7 @@ define('ABSPATH', dirname(__FILE__) . '/../'); define('WPINC', 'wp-includes-for-tests'); +define('HOUR_IN_SECONDS', 3600); require_once dirname(__FILE__) . '/../' . WPINC . '/file.php'; use org\bovigo\vfs\vfsStream; @@ -53,9 +54,12 @@ class WordPressStubs private $admin_initFunctions; private $options; private $metadata; + private $transients; private $calls; private $stubs; private $filters; + public $postmeta = 'wp_postmeta'; + public $last_error = ''; public function __construct($vfs) { @@ -98,6 +102,10 @@ public function __construct($vfs) $this->addMethod('get_locale'); $this->addMethod('wp_timezone_string'); $this->addMethod('update_option'); + $this->addMethod('update'); + $this->addMethod('get_transient'); + $this->addMethod('set_transient'); + $this->addMethod('delete_transient'); $this->addMethod('check_ajax_referer'); $this->addMethod('wp_json_encode'); $this->addMethod('wp_send_json_error'); @@ -122,6 +130,7 @@ public function defaults() $this->admin_initFunctions = array(); $this->options = new WordPressOptions(); $this->metadata = array(); + $this->transients = array(); $this->filters = array(); $GLOBALS['_wp_additional_image_sizes'] = array(); } @@ -185,6 +194,18 @@ public function call($method, $args) } if ('translate' === $method) { return $args[0]; + } elseif ('get_transient' === $method) { + $key = isset($args[0]) ? $args[0] : ''; + return isset($this->transients[$key]) ? $this->transients[$key] : false; + } elseif ('set_transient' === $method) { + $key = isset($args[0]) ? $args[0] : ''; + $value = isset($args[1]) ? $args[1] : ''; + $this->transients[$key] = $value; + return true; + } elseif ('delete_transient' === $method) { + $key = isset($args[0]) ? $args[0] : ''; + unset($this->transients[$key]); + return true; } elseif ('get_option' === $method) { return call_user_func_array(array($this->options, 'get'), $args); } elseif ('get_post_meta' === $method) { @@ -224,6 +245,11 @@ public function addOption($key, $value) $this->options->set($key, $value); } + public function addTransient($key, $value) + { + $this->transients[$key] = $value; + } + public function addImageSize($size, $values) { $GLOBALS['_wp_additional_image_sizes'][$size] = $values; diff --git a/test/unit/TinyImageTest.php b/test/unit/TinyImageTest.php index f70f3b4a..114e17c8 100644 --- a/test/unit/TinyImageTest.php +++ b/test/unit/TinyImageTest.php @@ -17,7 +17,7 @@ public function set_up() { } public function test_tiny_post_meta_key_may_never_change() { - $this->assertEquals( '61b16225f107e6f0a836bf19d47aa0fd912f8925', sha1( Tiny_Config::META_KEY ) ); + $this->assertEquals( '438fc52ce17b9aedf0cf70dea52d5551affba59a', sha1( Tiny_Config::META_KEY ) ); } public function test_update_wp_metadata_should_not_update_with_no_resized_original() { diff --git a/test/unit/TinyMigrateTest.php b/test/unit/TinyMigrateTest.php new file mode 100644 index 00000000..491a8543 --- /dev/null +++ b/test/unit/TinyMigrateTest.php @@ -0,0 +1,101 @@ +wp->stub('update', function() { + return 1; + }); + } + + /** + * Helper to check if a specific option update occurred. + */ + private function assertOptionWasUpdated($option, $value) + { + $calls = $this->wp->getCalls('update_option'); + foreach ($calls as $call) { + if (isset($call[0], $call[1]) && $call[0] === $option && $call[1] === $value) { + return $this->assertTrue(true); + } + } + $this->fail("Failed asserting that option '$option' was updated to '$value'."); + } + + public function test_run_skips_migration_when_db_version_is_current() + { + $this->wp->addOption(Tiny_Migrate::DB_VERSION_OPTION, Tiny_Migrate::DB_VERSION); + + Tiny_Migrate::run(); + + $this->assertCount(0, $this->wp->getCalls('update'), 'Should not touch DB if version matches.'); + } + + public function test_run_performs_migration_and_updates_version() + { + Tiny_Migrate::run(); + + $update_calls = $this->wp->getCalls('update'); + $this->assertCount(1, $update_calls); + + list($table, $data, $where) = $update_calls[0]; + + $this->assertEquals('wp_postmeta', $table); + $this->assertEquals(array('meta_key' => '_tiny_compress_images'), $data); + $this->assertEquals(array('meta_key' => 'tiny_compress_images'), $where); + + $this->assertOptionWasUpdated(Tiny_Migrate::DB_VERSION_OPTION, Tiny_Migrate::DB_VERSION); + } + + public function test_run_does_not_update_db_version_when_migration_fails() + { + $this->wp->stub('update', function() { return false; }); + + Tiny_Migrate::run(); + + $option_calls = $this->wp->getCalls('update_option'); + $version_updates = array_filter($option_calls, function($call) { return $call[0] === Tiny_Migrate::DB_VERSION_OPTION; }); + + $this->assertEmpty($version_updates, 'Should not update DB version when migration fails.'); + } + + public function test_run_does_not_update_option_if_unnecessary() + { + $this->wp->addOption(Tiny_Migrate::DB_VERSION_OPTION, Tiny_Migrate::DB_VERSION); + + Tiny_Migrate::run(); + + $this->assertEmpty($this->wp->getCalls('update_option'), 'Should not call update_option at all when version is already current.'); + } + + public function test_run_sets_backoff_transient_when_migration_fails() + { + $this->wp->stub('update', function() { return false; }); + + Tiny_Migrate::run(); + + $set_transient_calls = $this->wp->getCalls('set_transient'); + $this->assertCount(1, $set_transient_calls, 'A backoff transient should be set after a failed migration.'); + $this->assertEquals(Tiny_Migrate::MIGRATION_BACKOFF_KEY, $set_transient_calls[0][0]); + $this->assertEquals(HOUR_IN_SECONDS, $set_transient_calls[0][2]); + } + + public function test_run_skips_migration_when_backoff_transient_is_set() + { + $this->wp->stub('get_transient', function($key) { + return Tiny_Migrate::MIGRATION_BACKOFF_KEY === $key ? 1 : false; + }); + + Tiny_Migrate::run(); + + $this->assertCount(0, $this->wp->getCalls('update'), 'DB update should not be attempted during the backoff period.'); + } +} diff --git a/tiny-compress-images.php b/tiny-compress-images.php index e6aa9cad..8846f882 100644 --- a/tiny-compress-images.php +++ b/tiny-compress-images.php @@ -10,6 +10,7 @@ */ require dirname( __FILE__ ) . '/src/config/class-tiny-config.php'; +require dirname( __FILE__ ) . '/src/class-tiny-migrate.php'; require dirname( __FILE__ ) . '/src/class-tiny-helpers.php'; require dirname( __FILE__ ) . '/src/class-tiny-php.php'; require dirname( __FILE__ ) . '/src/class-tiny-wp-base.php'; @@ -37,6 +38,8 @@ require dirname( __FILE__ ) . '/src/class-tiny-compress-fopen.php'; } +add_action( 'plugins_loaded', array( 'Tiny_Migrate', 'run' ) ); + $tiny_plugin = new Tiny_Plugin(); register_uninstall_hook(