Commit 1b0b15caafe for woocommerce

commit 1b0b15caafe1f1230eae9da74789de66a02bc158
Author: Sakri Koskimies <sakri.koskimies@hotmail.com>
Date:   Tue Sep 8 06:48:20 2026 +0300

    fix: Preserve custom attachment metadata when regenerating images (#67458)

    * fix: Preserve custom attachment metadata when regenerating images

    Refs #60577

    * test: Remove only the added filter on teardown

    * test: Extract the shared image attachment fixtures into a trait

    * test: Assert that image regeneration actually ran

    * fix: Restore stored metadata when regeneration fails

    * test: Assert against the registered thumbnail size

    ---------

    Co-authored-by: Sakri Koskimies <sakri.koskimies@avenla.fi>

diff --git a/plugins/woocommerce/changelog/60577-fix-attachment-metadata-loss b/plugins/woocommerce/changelog/60577-fix-attachment-metadata-loss
new file mode 100644
index 00000000000..7acb401b086
--- /dev/null
+++ b/plugins/woocommerce/changelog/60577-fix-attachment-metadata-loss
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve third party attachment metadata keys when regenerating product images.
diff --git a/plugins/woocommerce/includes/class-wc-regenerate-images-request.php b/plugins/woocommerce/includes/class-wc-regenerate-images-request.php
index c53b784a3c4..c507926339f 100644
--- a/plugins/woocommerce/includes/class-wc-regenerate-images-request.php
+++ b/plugins/woocommerce/includes/class-wc-regenerate-images-request.php
@@ -148,7 +148,8 @@ class WC_Regenerate_Images_Request extends WC_Background_Process {
 			return false;
 		}

-		$old_metadata = wp_get_attachment_metadata( $this->attachment_id );
+		// Unfiltered, so the keys carried over below come from the stored value.
+		$old_metadata = wp_get_attachment_metadata( $this->attachment_id, true );

 		// We only want to regen WC images.
 		add_filter( 'intermediate_image_sizes', array( $this, 'adjust_intermediate_image_sizes' ) );
@@ -165,6 +166,10 @@ class WC_Regenerate_Images_Request extends WC_Background_Process {

 		// If something went wrong lets just remove the item from the queue.
 		if ( is_wp_error( $new_metadata ) || empty( $new_metadata ) ) {
+			if ( is_array( $old_metadata ) ) {
+				wp_update_attachment_metadata( $this->attachment_id, $old_metadata );
+			}
+
 			return false;
 		}

@@ -176,6 +181,11 @@ class WC_Regenerate_Images_Request extends WC_Background_Process {
 			}
 		}

+		// Restore the top level keys regeneration does not own.
+		if ( is_array( $old_metadata ) ) {
+			$new_metadata += $old_metadata;
+		}
+
 		// Update the meta data with the new size values.
 		wp_update_attachment_metadata( $this->attachment_id, $new_metadata );

diff --git a/plugins/woocommerce/includes/class-wc-regenerate-images.php b/plugins/woocommerce/includes/class-wc-regenerate-images.php
index fdfd39b39b3..c5f822a6080 100644
--- a/plugins/woocommerce/includes/class-wc-regenerate-images.php
+++ b/plugins/woocommerce/includes/class-wc-regenerate-images.php
@@ -427,7 +427,8 @@ class WC_Regenerate_Images {
 			}
 		}

-		$metadata = wp_get_attachment_metadata( $attachment_id );
+		// Unfiltered: this is the base for a write, so it must be the stored value.
+		$metadata = wp_get_attachment_metadata( $attachment_id, true );

 		// Fix for images with no metadata.
 		if ( ! is_array( $metadata ) ) {
@@ -445,6 +446,8 @@ class WC_Regenerate_Images {

 		// If something went wrong lets just return the original image.
 		if ( is_wp_error( $new_metadata ) || empty( $new_metadata ) ) {
+			wp_update_attachment_metadata( $attachment_id, $metadata );
+
 			return $image;
 		}

diff --git a/plugins/woocommerce/tests/legacy/bootstrap.php b/plugins/woocommerce/tests/legacy/bootstrap.php
index d78ee26040e..57a8af87ef5 100644
--- a/plugins/woocommerce/tests/legacy/bootstrap.php
+++ b/plugins/woocommerce/tests/legacy/bootstrap.php
@@ -342,6 +342,7 @@ class WC_Unit_Tests_Bootstrap {
 		require_once dirname( $this->tests_dir ) . '/php/helpers/LoggerSpyTrait.php';
 		require_once dirname( $this->tests_dir ) . '/php/helpers/MetaDataAssertionTrait.php';
 		require_once dirname( $this->tests_dir ) . '/php/helpers/CorePayPalGatewayTrait.php';
+		require_once dirname( $this->tests_dir ) . '/php/helpers/ImageAttachmentTrait.php';
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/helpers/ImageAttachmentTrait.php b/plugins/woocommerce/tests/php/helpers/ImageAttachmentTrait.php
new file mode 100644
index 00000000000..99c70024763
--- /dev/null
+++ b/plugins/woocommerce/tests/php/helpers/ImageAttachmentTrait.php
@@ -0,0 +1,113 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Helpers;
+
+/**
+ * Trait ImageAttachmentTrait.
+ *
+ * Image attachment fixtures backed by real files on disk, so tests can exercise code
+ * that reads the image itself rather than just the attachment post.
+ *
+ * The files land in the uploads directory, so call remove_added_uploads() from tearDown().
+ */
+trait ImageAttachmentTrait {
+
+	/**
+	 * Create an attachment backed by a real image file.
+	 *
+	 * @param int    $width    Width in pixels.
+	 * @param int    $height   Height in pixels.
+	 * @param string $filename File name to write into the uploads directory.
+	 * @return int Attachment ID.
+	 */
+	private function create_image_attachment( int $width, int $height, string $filename ): int {
+		$uploads = wp_upload_dir();
+		$file    = trailingslashit( $uploads['path'] ) . $filename;
+
+		wp_mkdir_p( $uploads['path'] );
+
+		$image = imagecreatetruecolor( $width, $height );
+		imagefilledrectangle( $image, 0, 0, $width, $height, imagecolorallocate( $image, 120, 60, 30 ) );
+		imagejpeg( $image, $file, 90 );
+		imagedestroy( $image );
+
+		$attachment_id = wp_insert_attachment(
+			array(
+				'post_mime_type' => 'image/jpeg',
+				'post_title'     => 'WC image attachment test',
+				'post_status'    => 'inherit',
+			),
+			$file
+		);
+
+		require_once ABSPATH . 'wp-admin/includes/image.php';
+		wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $file ) );
+
+		return $attachment_id;
+	}
+
+	/**
+	 * Add top level metadata keys that WordPress does not own.
+	 *
+	 * @param int   $attachment_id Attachment to add the keys to.
+	 * @param array $custom_keys   Metadata keyed by name.
+	 */
+	private function add_custom_attachment_metadata( int $attachment_id, array $custom_keys ): void {
+		$metadata = wp_get_attachment_metadata( $attachment_id, true );
+
+		wp_update_attachment_metadata( $attachment_id, array_merge( $metadata, $custom_keys ) );
+	}
+
+	/**
+	 * Delete a generated size, both the file and its metadata entry.
+	 *
+	 * @param int    $attachment_id Attachment to strip the size from.
+	 * @param string $size          Size name.
+	 * @return string Path of the deleted file.
+	 */
+	private function delete_attachment_size( int $attachment_id, string $size ): string {
+		$metadata = wp_get_attachment_metadata( $attachment_id, true );
+		$path     = $this->get_attachment_size_path( $attachment_id, $metadata, $size );
+
+		wp_delete_file( $path );
+
+		unset( $metadata['sizes'][ $size ] );
+		wp_update_attachment_metadata( $attachment_id, $metadata );
+
+		return $path;
+	}
+
+	/**
+	 * Assert that a size exists as a real image file of the dimensions its metadata records.
+	 *
+	 * @param int    $attachment_id Attachment to check.
+	 * @param string $size          Size name.
+	 */
+	private function assert_size_exists( int $attachment_id, string $size ): void {
+		$metadata = wp_get_attachment_metadata( $attachment_id, true );
+
+		$this->assertArrayHasKey( $size, $metadata['sizes'], "The $size size should be recorded in the metadata" );
+
+		$path = $this->get_attachment_size_path( $attachment_id, $metadata, $size );
+
+		$this->assertFileExists( $path, "The $size file should be on disk" );
+
+		$dimensions = getimagesize( $path );
+
+		$this->assertSame( (int) $metadata['sizes'][ $size ]['width'], $dimensions[0], "The $size file should be as wide as its metadata records" );
+		$this->assertSame( (int) $metadata['sizes'][ $size ]['height'], $dimensions[1], "The $size file should be as tall as its metadata records" );
+	}
+
+	/**
+	 * Absolute path of a generated size.
+	 *
+	 * @param int    $attachment_id Attachment the size belongs to.
+	 * @param array  $metadata      Stored attachment metadata.
+	 * @param string $size          Size name.
+	 * @return string
+	 */
+	private function get_attachment_size_path( int $attachment_id, array $metadata, string $size ): string {
+		return trailingslashit( dirname( get_attached_file( $attachment_id ) ) ) . $metadata['sizes'][ $size ]['file'];
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-regenerate-images-request-test.php b/plugins/woocommerce/tests/php/includes/class-wc-regenerate-images-request-test.php
new file mode 100644
index 00000000000..d3d707c68f4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-regenerate-images-request-test.php
@@ -0,0 +1,67 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Tests\Helpers\ImageAttachmentTrait;
+
+/**
+ * Tests for the WC_Regenerate_Images_Request class.
+ */
+class WC_Regenerate_Images_Request_Test extends WC_Unit_Test_Case {
+
+	use ImageAttachmentTrait;
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var WC_Regenerate_Images_Request
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new WC_Regenerate_Images_Request();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		$this->remove_added_uploads();
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Bulk regeneration should keep top level metadata keys it does not own.
+	 */
+	public function test_task_preserves_custom_metadata_keys(): void {
+		$attachment_id = $this->create_image_attachment( 1200, 800, 'wc-regen-request-test.jpg' );
+
+		$this->add_custom_attachment_metadata(
+			$attachment_id,
+			array(
+				'test_api_meta' => array( 'last_modified' => 1708332626 ),
+				'test_cdn_id'   => 'abc123',
+			)
+		);
+
+		$thumbnail = $this->delete_attachment_size( $attachment_id, 'woocommerce_thumbnail' );
+
+		$this->assertFileDoesNotExist( $thumbnail, 'The size should be missing before regeneration runs' );
+
+		$task = new ReflectionMethod( WC_Regenerate_Images_Request::class, 'task' );
+		$task->setAccessible( true );
+		$task->invoke( $this->sut, array( 'attachment_id' => $attachment_id ) );
+
+		$this->assert_size_exists( $attachment_id, 'woocommerce_thumbnail' );
+
+		$stored = get_post_meta( $attachment_id, '_wp_attachment_metadata', true );
+
+		$this->assertArrayHasKey( 'test_api_meta', $stored, 'Third party metadata keys should survive bulk regeneration' );
+		$this->assertArrayHasKey( 'test_cdn_id', $stored, 'Third party metadata keys should survive bulk regeneration' );
+		$this->assertNotEmpty( $stored['sizes'], 'Bulk regeneration should still write the generated sizes' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-regenerate-images-test.php b/plugins/woocommerce/tests/php/includes/class-wc-regenerate-images-test.php
new file mode 100644
index 00000000000..fc5ae4fed04
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-regenerate-images-test.php
@@ -0,0 +1,83 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Tests\Helpers\ImageAttachmentTrait;
+
+/**
+ * Tests for the WC_Regenerate_Images class.
+ */
+class WC_Regenerate_Images_Test extends WC_Unit_Test_Case {
+
+	use ImageAttachmentTrait;
+
+	/**
+	 * Metadata filter callback added by a test, removed on teardown.
+	 *
+	 * @var callable|null
+	 */
+	private $metadata_filter = null;
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		$this->remove_added_uploads();
+
+		if ( $this->metadata_filter ) {
+			remove_filter( 'wp_get_attachment_metadata', $this->metadata_filter );
+			$this->metadata_filter = null;
+		}
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Resizing an image on the fly should not persist a filtered view of the metadata.
+	 */
+	public function test_resize_does_not_persist_filtered_metadata(): void {
+		// Wide on purpose: maybe_resize_image() only regenerates on an aspect ratio mismatch.
+		$attachment_id = $this->create_image_attachment( 900, 300, 'wc-regen-test.jpg' );
+
+		$this->add_custom_attachment_metadata( $attachment_id, array( 'test_api_meta' => array( 'last_modified' => 1708332626 ) ) );
+
+		// A co-installed plugin hides its own key from readers.
+		$this->metadata_filter = function ( $data ) {
+			if ( is_array( $data ) ) {
+				unset( $data['test_api_meta'] );
+			}
+
+			return $data;
+		};
+		add_filter( 'wp_get_attachment_metadata', $this->metadata_filter );
+
+		$this->assertArrayNotHasKey( 'test_api_meta', wp_get_attachment_metadata( $attachment_id ), 'The filter should hide the key from readers' );
+		$this->assertArrayHasKey( 'test_api_meta', wp_get_attachment_metadata( $attachment_id, true ), 'The stored value should keep the key' );
+
+		$thumbnail = $this->delete_attachment_size( $attachment_id, 'woocommerce_thumbnail' );
+
+		$this->assertFileDoesNotExist( $thumbnail, 'The size should be missing before regeneration runs' );
+
+		// Full size dimensions, which is what image_downsize() returns when the size is missing.
+		$image    = array( wp_get_attachment_url( $attachment_id ), 900, 300, false );
+		$returned = WC_Regenerate_Images::maybe_resize_image( $image, $attachment_id, 'woocommerce_thumbnail', false );
+
+		// The registered size, not wc_get_image_size(), is what regeneration produces.
+		$target = wp_get_registered_image_subsizes()['woocommerce_thumbnail'];
+
+		$this->assertSame(
+			array( (int) $target['width'], (int) $target['height'] ),
+			array( $returned[1], $returned[2] ),
+			'The resized image should be returned, not the original'
+		);
+
+		$this->assert_size_exists( $attachment_id, 'woocommerce_thumbnail' );
+
+		$stored = get_post_meta( $attachment_id, '_wp_attachment_metadata', true );
+
+		$this->assertArrayHasKey(
+			'test_api_meta',
+			$stored,
+			'Metadata hidden by a wp_get_attachment_metadata filter should survive on-the-fly regeneration'
+		);
+	}
+}