Commit 72ffaddddeb for woocommerce

commit 72ffaddddeb5d078536556c1c3233a2238ab97ce
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Thu Oct 15 16:25:27 2026 +0300

    [tests] Compare the Products block with the classic template on three routes and cover Product Details, body classes, classic-theme patterns and decimal prices (#68870)

diff --git a/plugins/woocommerce/changelog/testops-234-restore-product-blocks-coverage b/plugins/woocommerce/changelog/testops-234-restore-product-blocks-coverage
new file mode 100644
index 00000000000..79d172bd6b1
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-restore-product-blocks-coverage
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Improve product-block regression coverage; no production change.
diff --git a/plugins/woocommerce/client/blocks/assets/js/utils/test/register-product-block-type.test.ts b/plugins/woocommerce/client/blocks/assets/js/utils/test/register-product-block-type.test.ts
index 7e60b7a7f4b..7693029859b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/utils/test/register-product-block-type.test.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/utils/test/register-product-block-type.test.ts
@@ -152,6 +152,70 @@ describe( 'registerProductBlockType', () => {
 		expect( mockUnregisterBlockType ).not.toHaveBeenCalled();
 	} );

+	it( 'allows product blocks directly in a per-product template', () => {
+		mockPostType = 'wp_template';
+		mockTemplateSlug = 'single-product-v-neck-t-shirt';
+		mockIsSiteEditor = true;
+		const registerProductBlockType = loadRegistrationFunction();
+
+		registerProductBlockType( {
+			...blockSettings,
+			name: 'woocommerce/per-product-template-block',
+		} );
+		mockContextSubscription();
+
+		expect( mockRegisterBlockType ).toHaveBeenCalledTimes( 2 );
+		expect( mockUnregisterBlockType ).toHaveBeenCalledWith(
+			'woocommerce/per-product-template-block'
+		);
+		expect( mockRegisterBlockType ).toHaveBeenLastCalledWith(
+			expect.objectContaining( {
+				name: 'woocommerce/per-product-template-block',
+				title: 'Test product block',
+			} ),
+			expect.objectContaining( {
+				ancestor: undefined,
+			} )
+		);
+	} );
+
+	it( 'registers eligible product blocks in the pattern editor without the Site Editor store', () => {
+		mockPostType = 'wp_block';
+		mockTemplateSlug = 'on-sale-products-pattern';
+		mockIsSiteEditor = false;
+		const registerProductBlockType = loadRegistrationFunction();
+
+		registerProductBlockType(
+			{
+				...blockSettings,
+				name: 'woocommerce/pattern-block',
+			},
+			{ isAvailableOnPostEditor: true }
+		);
+		registerProductBlockType( {
+			...blockSettings,
+			name: 'woocommerce/site-editor-only-block',
+		} );
+		mockContextSubscription();
+
+		expect( mockRegisterBlockType ).toHaveBeenCalledTimes( 1 );
+		expect( mockRegisterBlockType ).toHaveBeenCalledWith(
+			expect.objectContaining( {
+				name: 'woocommerce/pattern-block',
+				title: 'Test product block',
+			} ),
+			expect.objectContaining( {
+				ancestor: [ 'woocommerce/single-product' ],
+			} )
+		);
+		expect( mockRegisterBlockType ).not.toHaveBeenCalledWith(
+			expect.objectContaining( {
+				name: 'woocommerce/site-editor-only-block',
+			} ),
+			expect.anything()
+		);
+	} );
+
 	it( 're-registers a block with the ancestor required by each template', () => {
 		mockPostType = 'wp_template';
 		mockTemplateSlug = 'twentytwentyfour//coming-soon';
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts
index a192d7e60f9..824642a0666 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts
@@ -7,6 +7,7 @@ import { test as base, expect, BLOCK_THEME_SLUG } from '@woocommerce/e2e-utils';
  * Internal dependencies
  */
 import ProductCollectionPage, { SELECTORS } from './product-collection.page';
+import { getProductCollectionQuery } from '../products/utils';

 const test = base.extend< { pageObject: ProductCollectionPage } >( {
 	pageObject: async ( { page, admin, editor }, use ) => {
@@ -295,6 +296,7 @@ test.describe( 'Product Collection: Inspector Controls', () => {
 	} );

 	test( 'Products can be filtered based on price range.', async ( {
+		page,
 		pageObject,
 	} ) => {
 		await pageObject.createNewPostAndInsertBlock();
@@ -313,12 +315,24 @@ test.describe( 'Product Collection: Inspector Controls', () => {
 			max: '17.21',
 		} );

+		await expect
+			.poll(
+				async () =>
+					( await getProductCollectionQuery( page ) ).priceRange
+			)
+			.toEqual( { min: 15.28, max: 17.21 } );
 		await expect( pageObject.products ).toHaveCount( 2 );

 		await pageObject.setPriceRange( {
 			max: '17.29',
 		} );

+		await expect
+			.poll(
+				async () =>
+					( await getProductCollectionQuery( page ) ).priceRange
+			)
+			.toMatchObject( { max: 17.29 } );
 		await expect( pageObject.products ).toHaveCount( 5 );

 		await pageObject.publishAndGoToFrontend();
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/products/products.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/products/products.block_theme.spec.ts
index bd72b50a71d..b41b62a50ff 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/products/products.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/products/products.block_theme.spec.ts
@@ -4,8 +4,10 @@
 import {
 	test,
 	expect,
+	Admin,
 	BlockData,
 	BLOCK_THEME_SLUG,
+	RequestUtils,
 } from '@woocommerce/e2e-utils';

 /**
@@ -15,6 +17,7 @@ import {
 	getProductsNameFromClassicTemplate,
 	getProductCollectionQuery,
 	getProductsNameFromProductCollection,
+	getProductsNameFromProductQuery,
 	insertProductsQuery,
 } from './utils';

@@ -37,6 +40,36 @@ const templates = {
 	},
 };

+const classicTemplateComparisonRoutes = {
+	'archive-product': {
+		testTitle:
+			'Products block matches the classic template block on the shop page',
+		templateTitle: 'Product Catalog',
+		slug: 'archive-product',
+		frontendPage: '/shop/',
+		legacyBlockName: 'woocommerce/legacy-template',
+		needsCreation: false,
+	},
+	'taxonomy-product_cat': {
+		testTitle:
+			'Products block matches the classic template block on a product category archive',
+		templateTitle: 'Products by Category',
+		slug: 'taxonomy-product_cat',
+		frontendPage: '/product-category/tshirts/',
+		legacyBlockName: 'woocommerce/legacy-template',
+		needsCreation: true,
+	},
+	'product-search-results': {
+		testTitle:
+			'Products block matches the classic template block on product search results',
+		templateTitle: 'Product Search Results',
+		slug: 'product-search-results',
+		frontendPage: '/?s=shirt&post_type=product',
+		legacyBlockName: 'woocommerce/legacy-template',
+		needsCreation: false,
+	},
+};
+
 test.describe( `${ blockData.name } Block `, () => {
 	test( 'when Inherits Query From Template other options are hidden, show up otherwise', async ( {
 		admin,
@@ -161,3 +194,74 @@ for ( const {
 		} );
 	} );
 }
+
+const openRouteTemplateForEditing = async (
+	admin: Admin,
+	requestUtils: RequestUtils,
+	{
+		templateTitle,
+		slug,
+		needsCreation,
+	}: { templateTitle: string; slug: string; needsCreation: boolean }
+) => {
+	if ( needsCreation ) {
+		const template = await requestUtils.createTemplate( 'wp_template', {
+			slug,
+			title: templateTitle,
+			content: '',
+		} );
+
+		await admin.visitSiteEditor( {
+			postId: template.id,
+			postType: 'wp_template',
+			canvas: 'edit',
+		} );
+		return;
+	}
+
+	await admin.visitSiteEditor( {
+		postId: `${ BLOCK_THEME_SLUG }//${ slug }`,
+		postType: 'wp_template',
+		canvas: 'edit',
+	} );
+};
+
+for ( const {
+	testTitle,
+	templateTitle,
+	slug,
+	frontendPage,
+	legacyBlockName,
+	needsCreation,
+} of Object.values( classicTemplateComparisonRoutes ) ) {
+	test.describe( `${ templateTitle } template, Products block`, () => {
+		test( testTitle, async ( { admin, editor, page, requestUtils } ) => {
+			await openRouteTemplateForEditing( admin, requestUtils, {
+				templateTitle,
+				slug,
+				needsCreation,
+			} );
+			await editor.setContent( '' );
+			await insertProductsQuery( editor );
+			await editor.insertBlock( { name: legacyBlockName } );
+			await editor.canvas.locator( 'body' ).click();
+
+			await editor.saveSiteEditorEntities( {
+				isOnlyCurrentEntityDirty: true,
+			} );
+
+			await page.goto( frontendPage );
+			await expect(
+				page.locator( '.woocommerce-loop-product__title' ).first()
+			).toBeVisible();
+
+			const classicProducts =
+				await getProductsNameFromClassicTemplate( page );
+			const productQueryProducts =
+				await getProductsNameFromProductQuery( page );
+
+			expect( classicProducts.length ).toBeGreaterThan( 1 );
+			expect( classicProducts ).toEqual( productQueryProducts );
+		} );
+	} );
+}
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/products/utils.ts b/plugins/woocommerce/tests/e2e/tests/blocks/products/utils.ts
index a47c929b2e8..878124808d9 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/products/utils.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/products/utils.ts
@@ -9,6 +9,11 @@ export const getProductsNameFromClassicTemplate = async ( page: Page ) => {
 	return products.allTextContents();
 };

+export const getProductsNameFromProductQuery = async ( page: Page ) => {
+	const products = page.locator( '.wp-block-query .wp-block-post-title' );
+	return products.allTextContents();
+};
+
 export const getProductsNameFromProductCollection = async ( page: Page ) => {
 	const products = page.locator(
 		'.wp-block-woocommerce-product-collection .wp-block-post-title'
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
index 51461d96aab..8efcce53fd4 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
 use Automattic\WooCommerce\Tests\Blocks\BlockTypes\ProductCollection\Utils;
 use Automattic\WooCommerce\Tests\Blocks\Mocks\ProductCollectionMock;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Enums\TaxDisplayMode;
 use Automattic\WooCommerce\Internal\ProductFilters\QueryClauses;
 use WC_Helper_Product;
 use WP_Query;
@@ -1457,6 +1458,50 @@ class QueryBuilder extends \WP_UnitTestCase {
 					$query['orderBy']    = 'title';
 					$query['order']      = 'asc';
 					break;
+				case 'decimal-minimum-price':
+					// Pin the two options that decide the branch, so the price range filter
+					// always builds its non-tax-adjusted clause. The transaction rolls them back.
+					update_option( 'woocommerce_calc_taxes', 'no' );
+					update_option( 'woocommerce_tax_display_shop', TaxDisplayMode::EXCLUSIVE );
+
+					$decimal_product     = $fixtures->get_simple_product(
+						array(
+							'name'          => 'Decimal inspector target',
+							'regular_price' => '15.50',
+							'status'        => 'publish',
+							'stock_status'  => ProductStockStatus::IN_STOCK,
+						)
+					);
+					$products[]          = $decimal_product;
+					$query['post__in'][] = $decimal_product->get_id();
+					$query['priceRange'] = array(
+						'min' => 15.28,
+					);
+					$query['orderBy']    = 'title';
+					$query['order']      = 'asc';
+					break;
+				case 'decimal-maximum-price':
+					// Pin the two options that decide the branch, so the price range filter
+					// always builds its non-tax-adjusted clause. The transaction rolls them back.
+					update_option( 'woocommerce_calc_taxes', 'no' );
+					update_option( 'woocommerce_tax_display_shop', TaxDisplayMode::EXCLUSIVE );
+
+					$decimal_product     = $fixtures->get_simple_product(
+						array(
+							'name'          => 'Decimal inspector target',
+							'regular_price' => '10.25',
+							'status'        => 'publish',
+							'stock_status'  => ProductStockStatus::IN_STOCK,
+						)
+					);
+					$products[]          = $decimal_product;
+					$query['post__in'][] = $decimal_product->get_id();
+					$query['priceRange'] = array(
+						'max' => 10.28,
+					);
+					$query['orderBy']    = 'title';
+					$query['order']      = 'asc';
+					break;
 				case 'inclusive-price':
 					$query['priceRange'] = array(
 						'min' => 10,
@@ -1560,19 +1605,21 @@ class QueryBuilder extends \WP_UnitTestCase {
 	 */
 	public function inspector_query_result_provider(): array {
 		return array(
-			'category'         => array( 'category', array( 'Alpha inspector target' ) ),
-			'tag'              => array( 'tag', array( 'Alpha inspector target' ) ),
-			'brand'            => array( 'brand', array( 'Alpha inspector target' ) ),
-			'combined attrs'   => array( 'attributes', array( 'Alpha inspector target' ) ),
-			'keyword search'   => array( 'search', array( 'Alpha inspector target' ) ),
-			'stock'            => array( 'stock', array( 'Alpha inspector target' ) ),
-			'minimum price'    => array( 'minimum-price', array( 'Alpha inspector target', 'Zulu inspector distractor' ) ),
-			'maximum price'    => array( 'maximum-price', array( 'Alpha inspector target', 'Below inspector distractor' ) ),
-			'inclusive price'  => array( 'inclusive-price', array( 'Alpha inspector target' ) ),
-			'created before'   => array( 'created-before', array( 'Alpha inspector target' ) ),
-			'created within'   => array( 'created-within', array( 'Zulu inspector distractor' ) ),
-			'hand picked'      => array( 'hand-picked', array( 'Zulu inspector distractor', 'Alpha inspector target' ) ),
-			'title descending' => array( 'title-descending', array( 'Zulu inspector distractor', 'Alpha inspector target' ) ),
+			'category'              => array( 'category', array( 'Alpha inspector target' ) ),
+			'tag'                   => array( 'tag', array( 'Alpha inspector target' ) ),
+			'brand'                 => array( 'brand', array( 'Alpha inspector target' ) ),
+			'combined attrs'        => array( 'attributes', array( 'Alpha inspector target' ) ),
+			'keyword search'        => array( 'search', array( 'Alpha inspector target' ) ),
+			'stock'                 => array( 'stock', array( 'Alpha inspector target' ) ),
+			'minimum price'         => array( 'minimum-price', array( 'Alpha inspector target', 'Zulu inspector distractor' ) ),
+			'maximum price'         => array( 'maximum-price', array( 'Alpha inspector target', 'Below inspector distractor' ) ),
+			'decimal minimum price' => array( 'decimal-minimum-price', array( 'Decimal inspector target' ) ),
+			'decimal maximum price' => array( 'decimal-maximum-price', array( 'Alpha inspector target', 'Below inspector distractor', 'Decimal inspector target' ) ),
+			'inclusive price'       => array( 'inclusive-price', array( 'Alpha inspector target' ) ),
+			'created before'        => array( 'created-before', array( 'Alpha inspector target' ) ),
+			'created within'        => array( 'created-within', array( 'Zulu inspector distractor' ) ),
+			'hand picked'           => array( 'hand-picked', array( 'Zulu inspector distractor', 'Alpha inspector target' ) ),
+			'title descending'      => array( 'title-descending', array( 'Zulu inspector distractor', 'Alpha inspector target' ) ),
 		);
 	}

diff --git a/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php b/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php
index dd57bdb535b..343d033f3a8 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php
@@ -12,6 +12,15 @@ use WP_UnitTestCase;
  */
 class SingleProductTemplateTests extends WP_UnitTestCase {

+	/**
+	 * Remove the image files the attachment fixtures wrote into the uploads directory,
+	 * which the database rollback does not cover.
+	 */
+	public function tearDown(): void {
+		$this->remove_added_uploads();
+		parent::tearDown();
+	}
+
 	/**
 	 * @testdox Should not update Product Catalog template content so Single Product template logic does not leak into other templates.
 	 */
@@ -242,8 +251,73 @@ class SingleProductTemplateTests extends WP_UnitTestCase {
 			$this->assertContains( 'product', $filtered_classes );
 			$this->assertContains( $type_class, $filtered_classes );
 		} finally {
-			// tear_down() nulls $GLOBALS['post'] and rolls back the fixtures; these two
-			// globals it leaves exactly as the test left them.
+			// tear_down() nulls $GLOBALS['post'] and rolls back the fixtures;
+			// it leaves these two globals exactly as the test left them.
+			if ( $product_global_existed ) {
+				$GLOBALS['product'] = $product_global;
+			} else {
+				unset( $GLOBALS['product'] );
+			}
+
+			if ( $loop_global_existed ) {
+				$GLOBALS['woocommerce_loop'] = $loop_global;
+			} else {
+				unset( $GLOBALS['woocommerce_loop'] );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Should add has-post-thumbnail to the Single Product body classes only for a product that has an image.
+	 */
+	public function test_update_single_product_content_adds_has_post_thumbnail_only_for_imaged_product() {
+		$product_global_existed = array_key_exists( 'product', $GLOBALS );
+		$product_global         = $product_global_existed ? $GLOBALS['product'] : null;
+		$loop_global_existed    = array_key_exists( 'woocommerce_loop', $GLOBALS );
+		$loop_global            = $loop_global_existed ? $GLOBALS['woocommerce_loop'] : null;
+
+		try {
+			// The template under test installs its own body_class callback, so clear the
+			// stack first to isolate it. _restore_hooks() rebuilds the stack afterwards.
+			remove_all_filters( 'body_class' );
+
+			$imaged_product = \WC_Helper_Product::create_simple_product();
+			$imaged_product->set_image_id( $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ) );
+			$imaged_product->save();
+
+			$imageless_product = \WC_Helper_Product::create_simple_product();
+
+			$GLOBALS['product'] = $imaged_product;
+			$GLOBALS['post']    = get_post( $imaged_product->get_id() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The template reads the current post; tear_down() nulls it again.
+
+			$template          = new \WP_Block_Template();
+			$template->slug    = 'single-product';
+			$template->title   = 'Single Product';
+			$template->content = '<!-- wp:woocommerce/product-price /-->';
+			$template->type    = 'wp_template';
+
+			$single_product_template = new SingleProductTemplate();
+			$single_product_template->update_single_product_content( array( $template ) );
+
+			wc_reset_loop();
+			// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Exercise the public filter installed by the template under test.
+			$imaged_classes = apply_filters( 'body_class', array() );
+
+			$this->assertContains( 'status-publish', $imaged_classes, 'The imaged product body classes must contain status-publish.' );
+			$this->assertContains( 'has-post-thumbnail', $imaged_classes, 'A product with an image must contribute has-post-thumbnail.' );
+
+			$GLOBALS['product'] = $imageless_product;
+			$GLOBALS['post']    = get_post( $imageless_product->get_id() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The template reads the current post; tear_down() nulls it again.
+
+			wc_reset_loop();
+			// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Exercise the public filter installed by the template under test.
+			$imageless_classes = apply_filters( 'body_class', array() );
+
+			$this->assertContains( 'status-publish', $imageless_classes, 'The imageless product body classes must contain status-publish.' );
+			$this->assertNotContains( 'has-post-thumbnail', $imageless_classes, 'A product without an image must not contribute has-post-thumbnail.' );
+		} finally {
+			// tear_down() nulls $GLOBALS['post'] and rolls back the fixtures;
+			// it leaves these two globals exactly as the test left them.
 			if ( $product_global_existed ) {
 				$GLOBALS['product'] = $product_global;
 			} else {