diff --git a/src/class-tiny-diagnostics.php b/src/class-tiny-diagnostics.php
index 73f9c710..d805e80e 100644
--- a/src/class-tiny-diagnostics.php
+++ b/src/class-tiny-diagnostics.php
@@ -206,6 +206,12 @@ public function create_diagnostic_zip() {
}
$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
+ if ( false === $wp_filesystem ) {
+ return new WP_Error(
+ 'filesystem_unavailable',
+ __( 'WordPress filesystem could not be initialized.', 'tiny-compress-images' )
+ );
+ }
$temp_dir = trailingslashit( get_temp_dir() ) . 'tiny-compress-temp';
if ( ! $wp_filesystem->exists( $temp_dir ) ) {
wp_mkdir_p( $temp_dir );
@@ -249,6 +255,9 @@ public function create_diagnostic_zip() {
*/
public static function download_zip( $zip_path ) {
$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
+ if ( false === $wp_filesystem ) {
+ wp_die( esc_html__( 'WordPress filesystem could not be initialized.', 'tiny-compress-images' ) );
+ }
if ( ! $wp_filesystem->exists( $zip_path ) ) {
wp_die( esc_html__( 'Diagnostic file not found.', 'tiny-compress-images' ) );
}
diff --git a/src/class-tiny-helpers.php b/src/class-tiny-helpers.php
index dc4f41fb..4a5b4747 100644
--- a/src/class-tiny-helpers.php
+++ b/src/class-tiny-helpers.php
@@ -115,12 +115,10 @@ public static function get_mimetype( $input ) {
* Gets or initializes the WordPress filesystem instance.
*
* Returns the global WP_Filesystem instance, initializing it if necessary.
- * This helper prevents repeated initialization code throughout the plugin.
*
* @since 3.7.0
*
- * @return WP_Filesystem_Base The WP_Filesystem instance.
- * @throws Exception If the filesystem cannot be initialized.
+ * @return WP_Filesystem_Base|false The WP_Filesystem instance, or false on failure.
*/
public static function get_wp_filesystem() {
global $wp_filesystem;
@@ -129,14 +127,15 @@ public static function get_wp_filesystem() {
return $wp_filesystem;
}
- // Initialize the filesystem only if the function isn't available yet.
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
WP_Filesystem();
if ( ! ( $wp_filesystem instanceof WP_Filesystem_Base ) ) {
- throw new Exception( 'Unable to initialize WordPress filesystem.' );
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ error_log( 'Tiny Compress: Unable to initialize WordPress filesystem.' );
+ return false;
}
return $wp_filesystem;
diff --git a/src/class-tiny-image.php b/src/class-tiny-image.php
index 4b349eab..b4b32360 100644
--- a/src/class-tiny-image.php
+++ b/src/class-tiny-image.php
@@ -675,4 +675,184 @@ public function mark_as_compressed() {
$this->update_tiny_post_meta();
}
+
+ /**
+ * Retrieves the original image of the Tiny_Image
+ *
+ *
+ * @return Tiny_Image_Size|false the image or false if does not exist
+ */
+ private function get_original_image() {
+ $original_image = $this->get_image_size( self::ORIGINAL_UNSCALED );
+ if ( null === $original_image ) {
+ $original_image = $this->get_image_size();
+ }
+
+ if ( null === $original_image ) {
+ return false;
+ }
+
+ return $original_image;
+ }
+
+ /**
+ * Builds the filesystem path where the backup of the original image is
+ * (or would be) stored.
+ *
+ * @return string|false the backup file path, or false if there is no original image
+ */
+ private function get_backup_path() {
+ $original_image = $this->get_original_image();
+ if ( false === $original_image ) {
+ return false;
+ }
+
+ $file_path = $original_image->filename;
+ $upload_dir = wp_upload_dir();
+ $basedir = trailingslashit( $upload_dir['basedir'] );
+ if ( Tiny_Helpers::str_starts_with( $file_path, $basedir ) ) {
+ $file_path = substr( $file_path, strlen( $basedir ) );
+ }
+
+ return $basedir . 'tinify_backup/' . $file_path;
+ }
+
+ /**
+ * Creates a backup copy of the original image, if one does not already exist.
+ *
+ * @return bool true on success, false on failure or if a backup already exists
+ */
+ public function create_backup() {
+
+ $backup_file_path = $this->get_backup_path();
+ if ( false === $backup_file_path ) {
+ return false;
+ }
+
+ $wp_filesystem = Tiny_Helpers::get_wp_filesystem();
+ if ( false === $wp_filesystem ) {
+ return false;
+ }
+
+ if ( $wp_filesystem->exists( $backup_file_path ) ) {
+ return false;
+ }
+
+ $backup_dir = dirname( $backup_file_path );
+
+ if ( ! wp_mkdir_p( $backup_dir ) ) {
+ return false;
+ }
+
+ $original_image = $this->get_original_image();
+
+ return $wp_filesystem->copy( $original_image->filename, $backup_file_path );
+ }
+
+
+ /**
+ * Retrieves the public URL of the backup of the original image.
+ *
+ * @return string|false the backup URL, or false if no backup exists
+ */
+ public function get_backup() {
+ $backup_file_path = $this->get_backup_path();
+ if ( false === $backup_file_path ) {
+ return false;
+ }
+
+ $wp_filesystem = Tiny_Helpers::get_wp_filesystem();
+ if ( false === $wp_filesystem ) {
+ return false;
+ }
+
+ if ( ! $wp_filesystem->exists( $backup_file_path ) ) {
+ return false;
+ }
+
+ $upload_dir = wp_upload_dir();
+ $basedir = trailingslashit( $upload_dir['basedir'] );
+ $baseurl = trailingslashit( $upload_dir['baseurl'] );
+
+ return str_replace( $basedir, $baseurl, $backup_file_path );
+ }
+
+ /**
+ * Restores the original image from its backup.
+ *
+ * - Copies the backup file over the current original.
+ * - Clears compression metadata for all image sizes.
+ * - Regenerates all thumbnail sizes from the restored image.
+ * - Updates the WordPress attachment metadata.
+ *
+ * @since 3.7.0
+ *
+ * @return bool True on success, false if no backup exists or the copy fails.
+ */
+ public function restore_backup() {
+ $backup_file_path = $this->get_backup_path();
+ if ( false === $backup_file_path ) {
+ return false;
+ }
+
+ $wp_filesystem = Tiny_Helpers::get_wp_filesystem();
+ if ( false === $wp_filesystem ) {
+ return false;
+ }
+
+ if ( ! $wp_filesystem->exists( $backup_file_path ) ) {
+ return false;
+ }
+
+ $original_image = $this->get_original_image();
+ if ( false === $original_image ) {
+ return false;
+ }
+
+ if ( ! $wp_filesystem->copy( $backup_file_path, $original_image->filename, true ) ) {
+ return false;
+ }
+
+ // Clear compression metadata for all image sizes.
+ foreach ( $this->sizes as $size ) {
+ $size->meta = array();
+ }
+ $this->update_tiny_post_meta();
+
+ // Regenerate all thumbnail sizes from the restored image.
+ $new_metadata = wp_generate_attachment_metadata( $this->id, $original_image->filename );
+ if ( $new_metadata ) {
+ $this->wp_metadata = $new_metadata;
+ wp_update_attachment_metadata( $this->id, $this->wp_metadata );
+ $this->sizes = array();
+ $this->parse_wp_metadata();
+ }
+
+ return true;
+ }
+
+ /**
+ * Deletes the backup file of the original image, if it exists.
+ *
+ * @since 3.7.0
+ *
+ * @return bool True on success or if no backup exists, false on deletion failure.
+ */
+ public function delete_backup() {
+ $backup_file_path = $this->get_backup_path();
+ if ( false === $backup_file_path ) {
+ return true;
+ }
+
+ $wp_filesystem = Tiny_Helpers::get_wp_filesystem();
+ if ( false === $wp_filesystem ) {
+ return false;
+ }
+
+ if ( ! $wp_filesystem->exists( $backup_file_path ) ) {
+ return true;
+ }
+
+ return $wp_filesystem->delete( $backup_file_path );
+ }
}
diff --git a/src/class-tiny-logger.php b/src/class-tiny-logger.php
index b50da5be..3ae23aaa 100644
--- a/src/class-tiny-logger.php
+++ b/src/class-tiny-logger.php
@@ -179,6 +179,9 @@ private function log( $level, $message, $context = array() ) {
// Ensure log directory exists.
$log_dir = dirname( $this->log_file_path );
$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
+ if ( false === $wp_filesystem ) {
+ return;
+ }
if ( ! $wp_filesystem->exists( $log_dir ) ) {
wp_mkdir_p( $log_dir );
self::create_blocking_files( $log_dir );
@@ -201,6 +204,9 @@ private function log( $level, $message, $context = array() ) {
*/
private function rotate_logs() {
$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
+ if ( false === $wp_filesystem ) {
+ return;
+ }
if ( ! $wp_filesystem->exists( $this->log_file_path ) ) {
return;
}
@@ -222,6 +228,9 @@ public static function clear_logs() {
$instance = self::get_instance();
$log_path = $instance->get_log_file_path();
$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
+ if ( false === $wp_filesystem ) {
+ return false;
+ }
$file_exits = $wp_filesystem->exists( $log_path );
if ( $file_exits ) {
return $wp_filesystem->delete( $log_path );
@@ -239,6 +248,9 @@ public static function clear_logs() {
*/
private static function create_blocking_files( $log_dir ) {
$wp_filesystem = Tiny_Helpers::get_wp_filesystem();
+ if ( false === $wp_filesystem ) {
+ return;
+ }
$index_file = trailingslashit( $log_dir ) . 'index.html';
if ( ! $wp_filesystem->exists( $index_file ) ) {
diff --git a/src/class-tiny-plugin.php b/src/class-tiny-plugin.php
index a96b9f4f..3110c904 100644
--- a/src/class-tiny-plugin.php
+++ b/src/class-tiny-plugin.php
@@ -119,6 +119,11 @@ public function ajax_init() {
$this->get_method( 'mark_image_as_compressed' )
);
+ add_action(
+ 'wp_ajax_tiny_restore_backup',
+ $this->get_method( 'restore_backup_image' )
+ );
+
/*
When touching any functionality linked to image compressions when
uploading images make sure it also works with XML-RPC. See README. */
@@ -746,7 +751,7 @@ public function render_media_column( $column, $id ) {
if ( self::MEDIA_COLUMN === $column ) {
$tiny_image = new Tiny_Image( $this->settings, $id );
if ( $tiny_image->file_type_allowed() ) {
- echo '
';
+ echo '
';
$this->render_compress_details( $tiny_image );
echo '
';
}
@@ -761,7 +766,8 @@ public function show_media_info() {
echo '
';
esc_html_e( 'JPEG, PNG, & WebP optimization', 'tiny-compress-images' );
echo '
';
- echo '
';
+ echo '
';
$this->render_compress_details( $tiny_image );
echo '
';
echo '
';
@@ -909,6 +915,7 @@ public function friendly_user_name() {
public function clean_attachment( $post_id ) {
$tiny_image = new Tiny_Image( $this->settings, $post_id );
$tiny_image->delete_converted_image();
+ $tiny_image->delete_backup();
}
/**
@@ -934,37 +941,7 @@ public function backup_original_image( $attachment_id ) {
$tiny_image = new Tiny_Image( $this->settings, $attachment_id );
- $original_image = $tiny_image->get_image_size( Tiny_Image::ORIGINAL_UNSCALED );
- if ( null === $original_image ) {
- $original_image = $tiny_image->get_image_size();
- }
-
- if ( null === $original_image ) {
- return false;
- }
-
- $file_path = $original_image->filename;
- $upload_dir = wp_upload_dir();
- $basedir = trailingslashit( $upload_dir['basedir'] );
- if ( Tiny_Helpers::str_starts_with( $file_path, $basedir ) ) {
- $file_path = substr( $file_path, strlen( $basedir ) );
- }
-
- $backup_file = $basedir . 'tinify_backup/' . $file_path;
-
- $wp_filesystem = Tiny_Helpers::get_wp_filesystem();
-
- if ( $wp_filesystem->exists( $backup_file ) ) {
- return false;
- }
-
- $backup_dir = dirname( $backup_file );
-
- if ( ! wp_mkdir_p( $backup_dir ) ) {
- return false;
- }
-
- return $wp_filesystem->copy( $original_image->filename, $backup_file );
+ return $tiny_image->create_backup();
}
public static function request_review() {
@@ -989,6 +966,39 @@ public static function uninstall() {
Tiny_Apache_Rewrite::uninstall_rules();
}
+ /**
+ * Restores the original image from its backup via AJAX.
+ *
+ * Validates the request, calls restore_backup() on the image, then
+ * re-renders the compression details partial in the response.
+ *
+ * @since 3.7.0
+ *
+ * @return void
+ */
+ public function restore_backup_image() {
+ $response = $this->validate_ajax_attachment_request();
+ if ( isset( $response['error'] ) ) {
+ echo esc_html( $response['error'] );
+ exit();
+ }
+
+ list($id, $metadata) = $response['data'];
+ $tiny_image = new Tiny_Image( $this->settings, $id, $metadata );
+
+ if ( ! $tiny_image->restore_backup() ) {
+ echo esc_html__(
+ 'Could not restore backup. The backup file may not exist or could not be written.',
+ 'tiny-compress-images'
+ );
+ exit();
+ }
+
+ $this->render_compress_details( $tiny_image );
+
+ exit();
+ }
+
public function mark_image_as_compressed() {
$response = $this->validate_ajax_attachment_request();
if ( isset( $response['error'] ) ) {
diff --git a/src/css/admin.css b/src/css/admin.css
index 977dadf1..1356252e 100644
--- a/src/css/admin.css
+++ b/src/css/admin.css
@@ -412,7 +412,7 @@ input[type=number][name*="tinypng_resize_original"] {
}
.tiny-compression-details {
- padding: 10px;
+ padding: 10px 0;
}
.tiny-compression-details table {
@@ -481,4 +481,21 @@ fieldset.tinypng_convert_fields[disabled] {
.tiny-mt-2 {
margin-top: 10px;
+}
+
+.tiny-dialog {
+ padding: 20px;
+ border: 1px solid #ccd0d4;
+ border-radius: 4px;
+ box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
+}
+
+.tiny-dialog-error {
+ color: #dc3232;
+}
+
+.tiny-dialog-actions {
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
}
\ No newline at end of file
diff --git a/src/js/admin.js b/src/js/admin.js
index 03312c0a..7b177ff3 100644
--- a/src/js/admin.js
+++ b/src/js/admin.js
@@ -1,4 +1,69 @@
-(function() {
+(function () {
+ async function restoreBackup(attachmentId) {
+ return jQuery.ajax({
+ url: ajaxurl,
+ type: 'POST',
+ data: {
+ _nonce: tinyCompress.nonce,
+ action: 'tiny_restore_backup',
+ id: attachmentId,
+ },
+ });
+ }
+
+ jQuery(document).on('click', 'a[data-dialog-id]', function (e) {
+ e.preventDefault();
+ const trigger = jQuery(e.currentTarget);
+ const dialogID = trigger.data('dialog-id');
+ if (!dialogID) {
+ return;
+ }
+
+ const dialog = document.getElementById(dialogID);
+ if (!dialog) {
+ return;
+ }
+
+ const attachmentId = trigger.data('id');
+ const container = document.querySelector(`[data-tiny-media-id="${attachmentId}"]`);
+
+ dialog.showModal();
+
+ dialog.addEventListener('cancel', async (e) => {
+ e.preventDefault();
+ const spinner = dialog.querySelector('.spinner');
+ try {
+ if (spinner) {
+ spinner.style.visibility = 'visible';
+ }
+ const result = await restoreBackup(attachmentId);
+ dialog.close();
+
+ // refresh thickbox
+ const modal = container.querySelector('.modal');
+ const ajaxContent = document.getElementById('TB_ajaxContent');
+ if (modal && ajaxContent) {
+ modal.append(...ajaxContent.children);
+ }
+
+ container.innerHTML = result;
+ if (typeof tb_remove === 'function') {
+ tb_remove();
+ }
+ } catch (err) {
+ const errorEl = dialog.querySelector('.tiny-dialog-error');
+ if (errorEl) {
+ errorEl.textContent = err.responseText || 'Failed to restore backup.';
+ errorEl.hidden = false;
+ }
+ } finally {
+ if (spinner) {
+ spinner.style.visibility = 'hidden';
+ }
+ }
+ }, { once: true });
+ });
+
function downloadDiagnostics() {
try {
jQuery('#download-diagnostics-spinner').show();
diff --git a/src/views/compress-details-backup.php b/src/views/compress-details-backup.php
new file mode 100644
index 00000000..c531f45c
--- /dev/null
+++ b/src/views/compress-details-backup.php
@@ -0,0 +1,50 @@
+settings->get_backup_enabled();
+
+?>
+get_backup();
+ $modal_id = 'modal_' . absint( $tiny_image->get_id() ) . '_backup';
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/views/compress-details.php b/src/views/compress-details.php
index 7c3152f9..d9b486a9 100644
--- a/src/views/compress-details.php
+++ b/src/views/compress-details.php
@@ -10,6 +10,7 @@
$available_sizes = array_keys( $this->settings->get_sizes() );
$conversion_enabled = $this->settings->get_conversion_enabled();
$active_sizes = $this->settings->get_sizes();
+$backup_enabled = $this->settings->get_backup_enabled();
$active_tinify_sizes = $this->settings->get_active_tinify_sizes();
$error = $tiny_image->get_latest_error();
$total = $tiny_image->get_count( array( 'modified', 'missing', 'has_been_compressed', 'compressed', 'has_been_converted' ) );
@@ -19,8 +20,9 @@
$size_before = $image_statistics['initial_total_size'];
$size_after = $image_statistics['compressed_total_size'];
-$size_active = array_fill_keys( $active_tinify_sizes, true );
-$size_exists = array_fill_keys( $available_sizes, true );
+$size_active = array_fill_keys( $active_tinify_sizes, true );
+$size_exists = array_fill_keys( $available_sizes, true );
+$has_compressed = $size_before - $size_after;
ksort( $size_exists );
?>
@@ -111,8 +113,12 @@
-
-
+ get_name() );
+ ?>
+
@@ -138,12 +144,6 @@
-
- get_name() ) );
- ?>
-
|
@@ -244,7 +244,7 @@
+
+
diff --git a/test/helpers/wordpress.php b/test/helpers/wordpress.php
index 41972e61..3aaa007c 100644
--- a/test/helpers/wordpress.php
+++ b/test/helpers/wordpress.php
@@ -92,6 +92,8 @@ public function __construct($vfs)
$this->addMethod('is_multisite');
$this->addMethod('current_user_can');
$this->addMethod('wp_get_attachment_metadata');
+ $this->addMethod('wp_generate_attachment_metadata');
+ $this->addMethod('wp_update_attachment_metadata');
$this->addMethod('is_admin');
$this->addMethod('is_customize_preview');
$this->addMethod('is_plugin_active');
@@ -305,14 +307,11 @@ public function stub($method, $func)
public function createImage($file_size, $path, $name)
{
- if (! $this->vfs->hasChild(self::UPLOAD_DIR . "/$path")) {
- vfsStream::newDirectory(self::UPLOAD_DIR . "/$path")->at($this->vfs);
+ $full_dir = $this->vfs->url() . '/' . self::UPLOAD_DIR . '/' . $path;
+ if (! is_dir($full_dir)) {
+ mkdir($full_dir, 0777, true);
}
- $dir = $this->vfs->getChild(self::UPLOAD_DIR . "/$path");
-
- vfsStream::newFile($name)
- ->withContent(new LargeFileContent($file_size))
- ->at($dir);
+ file_put_contents($full_dir . '/' . $name, str_repeat("\x00", $file_size));
}
/**
diff --git a/test/unit/TinyPluginBackupTest.php b/test/unit/TinyPluginBackupTest.php
index 5e3a28b4..8b98e2d8 100644
--- a/test/unit/TinyPluginBackupTest.php
+++ b/test/unit/TinyPluginBackupTest.php
@@ -4,6 +4,7 @@
use function PHPUnit\Framework\assertFalse;
use function PHPUnit\Framework\assertTrue;
+use function PHPUnit\Framework\assertEquals;
class Tiny_Plugin_Backup_Test extends Tiny_TestCase
{
@@ -146,4 +147,115 @@ public function test_will_backup_unscaled_original_when_exists()
assertTrue($backup_made, 'expected backup of unscaled original to be made');
assertTrue(file_exists($expected_backup), 'expected backup of unscaled original to be created');
}
+
+ public function test_restore_backup_returns_false_when_no_backup_exists()
+ {
+ $this->wp->createImage( 37857, '2026/04', 'testfile.png' );
+
+ $wp_metadata = array(
+ 'file' => '2026/04/testfile.png',
+ 'sizes' => array(),
+ );
+ $mock_settings = $this->createMock( Tiny_Settings::class );
+ $tiny_image = new Tiny_Image( $mock_settings, 1, $wp_metadata, null, array(), array() );
+
+ $result = $tiny_image->restore_backup();
+
+ assertFalse( $result, 'expected restore to return false when no backup exists' );
+ }
+
+ public function test_restore_backup_restores_file_from_backup()
+ {
+ // Create only the directory (via a placeholder) so the restore can write testfile.png
+ // without having to overwrite a LargeFileContent vfsStream file, which is read-only.
+ $this->wp->createImage( 1, '2026/04', '_placeholder.png' );
+ $this->wp->createImage( 100000, 'tinify_backup/2026/04', 'testfile.png' );
+
+ $this->wp->stub( 'wp_generate_attachment_metadata', function ( $id, $file ) {
+ return array(
+ 'file' => '2026/04/testfile.png',
+ 'sizes' => array(),
+ );
+ } );
+
+ $wp_metadata = array(
+ 'file' => '2026/04/testfile.png',
+ 'sizes' => array(),
+ );
+ $mock_settings = $this->createMock( Tiny_Settings::class );
+ $tiny_image = new Tiny_Image( $mock_settings, 1, $wp_metadata, null, array(), array() );
+
+ $result = $tiny_image->restore_backup();
+
+ $original_path = $this->vfs->url() . '/wp-content/uploads/2026/04/testfile.png';
+ assertTrue( $result, 'expected restore to return true' );
+ assertEquals( 100000, filesize( $original_path ), 'expected original file to be overwritten with backup content' );
+ }
+
+ public function test_restore_backup_clears_all_sizes_metadata()
+ {
+ // Create only thumbnail file (not testfile.png) so the restore writes testfile.png fresh.
+ $this->wp->createImage( 5000, '2026/04', 'testfile-150x150.png' );
+ $this->wp->createImage( 100000, 'tinify_backup/2026/04', 'testfile.png' );
+
+ $this->wp->stub( 'wp_generate_attachment_metadata', function ( $id, $file ) {
+ return array(
+ 'file' => '2026/04/testfile.png',
+ 'sizes' => array(),
+ );
+ } );
+
+ $wp_metadata = array(
+ 'file' => '2026/04/testfile.png',
+ 'sizes' => array(
+ 'thumbnail' => array( 'file' => 'testfile-150x150.png', 'width' => 150, 'height' => 150 ),
+ ),
+ );
+ $tiny_metadata = array(
+ Tiny_Image::ORIGINAL => array( 'input' => array( 'size' => 37857 ), 'output' => array( 'size' => 30000 ) ),
+ 'thumbnail' => array( 'input' => array( 'size' => 5000 ), 'output' => array( 'size' => 4000 ) ),
+ );
+ $mock_settings = $this->createMock( Tiny_Settings::class );
+ $mock_settings->method( 'get_sizes' )->willReturn( array() );
+ $mock_settings->method( 'get_active_tinify_sizes' )->willReturn( array() );
+ $tiny_image = new Tiny_Image( $mock_settings, 1, $wp_metadata, $tiny_metadata );
+
+ $tiny_image->restore_backup();
+
+ assertEquals( array(), $tiny_image->get_image_size( Tiny_Image::ORIGINAL )->meta, 'expected original size metadata to be cleared' );
+ assertEquals( array(), $tiny_image->get_image_size( 'thumbnail' )->meta, 'expected thumbnail size metadata to be cleared' );
+ }
+
+ public function test_clean_attachment_deletes_backup_file()
+ {
+ $this->wp->createImage( 37857, '2026/04', 'testfile.png' );
+ $this->wp->createImage( 100000, 'tinify_backup/2026/04', 'testfile.png' );
+ $backup_path = $this->vfs->url() . '/wp-content/uploads/tinify_backup/2026/04/testfile.png';
+
+ $this->wp->stub( 'wp_get_attachment_metadata', function ( $i ) {
+ return array(
+ 'file' => '2026/04/testfile.png',
+ 'sizes' => array(),
+ );
+ } );
+
+ $tiny_plugin = new Tiny_Plugin();
+ $ref = new \ReflectionClass( $tiny_plugin );
+ $settings_prop = $ref->getProperty( 'settings' );
+ $settings_prop->setAccessible( true );
+ $mock_settings = $this->createMock( Tiny_Settings::class );
+ $settings_prop->setValue( $tiny_plugin, $mock_settings );
+
+ assertTrue( file_exists( $backup_path ), 'expected backup to exist before clean_attachment' );
+ $tiny_plugin->clean_attachment( 1 );
+ assertFalse( file_exists( $backup_path ), 'expected backup to be deleted after clean_attachment' );
+ }
+
+ public function test_ajax_init_adds_restore_backup_action()
+ {
+ $tiny_plugin = new Tiny_Plugin();
+ $tiny_plugin->ajax_init();
+
+ WordPressStubs::assertHook( 'wp_ajax_tiny_restore_backup', array( $tiny_plugin, 'restore_backup_image' ) );
+ }
}
diff --git a/test/wp-includes-for-tests/file.php b/test/wp-includes-for-tests/file.php
index c5ea20a6..ab529fee 100644
--- a/test/wp-includes-for-tests/file.php
+++ b/test/wp-includes-for-tests/file.php
@@ -15,7 +15,14 @@ public function copy( $src, $dest, $overwrite = false, $mode = false ) {
if ( ! $overwrite && file_exists( $dest ) ) {
return false;
}
- return copy( $src, $dest );
+ // Use file_get_contents + file_put_contents instead of copy() so that
+ // vfsStream files backed by LargeFileContent (read-only virtual content)
+ // can be overwritten in tests.
+ $content = file_get_contents( $src );
+ if ( false === $content ) {
+ return false;
+ }
+ return file_put_contents( $dest, $content ) !== false;
}
public function get_contents( $path ) {