Commit 9daa7098994 for woocommerce

commit 9daa7098994495a3e2f4bba06e78bb0e5b944eda
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date:   Thu Nov 12 11:04:37 2026 +0000

    Add regression coverage for product batch image errors (#70912)

    * Add regression coverage for product batch image errors

    * Add changelog entry for product image regression tests

diff --git a/plugins/woocommerce/changelog/45268-product-batch-image-error-regression-tests b/plugins/woocommerce/changelog/45268-product-batch-image-error-regression-tests
new file mode 100644
index 00000000000..fb18290e8aa
--- /dev/null
+++ b/plugins/woocommerce/changelog/45268-product-batch-image-error-regression-tests
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Cover product image errors and batch response consistency; tests only, no production change.
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-image-upload-cleanup-test.php b/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-image-upload-cleanup-test.php
index b4b0bf1ec35..33f9eee224a 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-image-upload-cleanup-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-image-upload-cleanup-test.php
@@ -183,6 +183,127 @@ class WC_REST_Image_Upload_Cleanup_Test extends WC_REST_Unit_Test_Case {
 		$this->assertSame( array(), $this->attachment_ids );
 	}

+	/**
+	 * @testdox An image error after a valid featured image does not create an unreported product, including in a batch.
+	 * @testWith [false, true]
+	 *           [true, true]
+	 *           [false, false]
+	 *           [true, false]
+	 *
+	 * @param bool $batch Whether to use the batch endpoint.
+	 * @param bool $upload_error Whether to fail an upload rather than reject an existing non-image ID.
+	 */
+	public function test_later_image_error_does_not_create_unreported_product( bool $batch, bool $upload_error ): void {
+		$invalid_image = $upload_error
+			? array( 'src' => 'https://example.org/rejected.php' )
+			: array( 'id' => self::factory()->post->create() );
+		$product_data  = array(
+			'name'   => 'Product with rejected image',
+			'sku'    => 'rejected-image-product',
+			'images' => array(
+				array( 'src' => 'https://example.org/featured.png' ),
+				$invalid_image,
+			),
+		);
+		$request       = new WP_REST_Request( 'POST', '/wc/v3/products' . ( $batch ? '/batch' : '' ) );
+		$request->set_body_params(
+			$batch ? array(
+				'create' => array(
+					array(
+						'name'   => 'Product before image error',
+						'sku'    => 'before-image-error',
+						'images' => array( array( 'src' => 'https://example.org/before.png' ) ),
+					),
+					$product_data,
+					array(
+						'name'   => 'Product after image error',
+						'sku'    => 'after-image-error',
+						'images' => array( array( 'src' => 'https://example.org/after.png' ) ),
+					),
+				),
+			) : $product_data
+		);
+
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+		$this->assertSame( $batch ? 200 : 400, $response->get_status() );
+
+		if ( $batch ) {
+			$this->assertCount( 3, $data['create'], 'Each batch item must have a response, even after an image error.' );
+			$this->assertSame( 0, $data['create'][1]['id'] );
+			foreach ( array( 0, 2 ) as $index ) {
+				$item = $data['create'][ $index ];
+				$this->assertArrayNotHasKey( 'error', $item );
+				$this->assertGreaterThan( 0, $item['id'] );
+				$this->assertSame( $item['id'], wc_get_product_id_by_sku( $item['sku'] ), 'Successful items must return the persisted product ID.' );
+				$this->assertInstanceOf( WP_Post::class, get_post( $item['images'][0]['id'] ), 'An image error must not delete another batch item\'s image.' );
+			}
+		}
+
+		$error = $batch ? $data['create'][1]['error'] : $data;
+		$this->assertSame( $upload_error ? 'woocommerce_product_image_upload_error' : 'woocommerce_product_invalid_image_id', $error['code'] );
+		$this->assertSame( 400, $error['data']['status'] );
+		$this->assertSame( 0, wc_get_product_id_by_sku( $product_data['sku'] ), 'An image error must not reserve the failed product\'s SKU.' );
+		$this->assertSame(
+			array(),
+			get_posts(
+				array(
+					'post_type'   => 'product',
+					'post_status' => 'any',
+					'title'       => $product_data['name'],
+					'fields'      => 'ids',
+				)
+			),
+			'An image error must not leave a product without its ID in the response.'
+		);
+		$this->assertCount( $batch ? 3 : 1, $this->uploads, 'The featured image must be uploaded before the later image fails.' );
+		$failed_image_index = $batch ? 1 : 0;
+		$this->assertFileDoesNotExist( $this->uploads[ $failed_image_index ]['file'] );
+		$this->assertNull( get_post( $this->attachment_ids[ $failed_image_index ] ), 'The failed product\'s new featured image must be deleted.' );
+	}
+
+	/**
+	 * @testdox Suppressing an image upload error still returns the created product ID and keeps its valid images in a batch.
+	 */
+	public function test_batch_suppressed_image_error_returns_created_product(): void {
+		add_filter( 'woocommerce_rest_suppress_image_upload_error', '__return_true' );
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products/batch' );
+		$request->set_body_params(
+			array(
+				'create' => array(
+					array(
+						'name'   => 'Product with suppressed image error',
+						'sku'    => 'suppressed-image-error',
+						'images' => array(
+							array( 'src' => 'https://example.org/featured.png' ),
+							array( 'src' => 'https://example.org/rejected.php' ),
+						),
+					),
+					array(
+						'name'   => 'Product after suppressed image error',
+						'sku'    => 'after-suppressed-image-error',
+						'images' => array( array( 'src' => 'https://example.org/after.png' ) ),
+					),
+				),
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertCount( 2, $data['create'] );
+		$this->assertCount( 2, $this->uploads );
+		foreach ( $data['create'] as $index => $item ) {
+			$this->assertArrayNotHasKey( 'error', $item );
+			$this->assertGreaterThan( 0, $item['id'] );
+			$this->assertSame( $item['id'], wc_get_product_id_by_sku( $item['sku'] ) );
+			$this->assertCount( 1, $item['images'] );
+			$this->assertSame( $this->attachment_ids[ $index ], $item['images'][0]['id'] );
+			$this->assertFileExists( $this->uploads[ $index ]['file'] );
+		}
+	}
+
 	/**
 	 * @testdox Other image-helper callers also delete their rejected new downloads.
 	 * @testWith ["utility"]