Commit 8f4c5c66b07 for woocommerce
commit 8f4c5c66b079a70b2b87ecd7922a96950fedbd6c
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date: Wed Nov 11 09:54:08 2026 +0100
Test email product blocks and fix their prices and coupon codes (#70807)
* Show one price for variable products with equal variation prices
A product collection in an email showed "$15.00 — $15.00" when every
variation cost the same. The shop shows a single price there.
* Accept any value in the coupon code generator filter callback
The callback typed its parameters, so a plugin returning null from
woocommerce_coupon_code_block_auto_generate made the email render fail
with a TypeError. Values from the filter are now checked instead.
* Test the email product button, sale badge and coupon code blocks
These blocks decide links, labels and coupon placement in customer
emails, and only an RTL alignment test covered them.
* Match the shop for grouped, unpriced and on-sale variable product prices
Emails showed a range for grouped items at one price, $0.00 for a
variable product without priced variations, and no struck-through
regular price for a variable product on sale at one price.
* Check the coupon code filter value before it reaches the email
The block printed whatever the filter returned, so an array showed as
"Array" and an object failed the render, and the generator replaced a
numeric code from another plugin with a new coupon. Tests now also
cover the generator wired through Bootstrap and existing coupons.
* Check the sale badge's own alignment styles in its test
The test read the wrapper cell's text-align, so it passed even when the
badge itself lost its alignment.
* Leave hidden grouped items out of the email price range
A draft or hidden item of a grouped product could widen the range in
the email, while the shop only counts visible items.
* Check the sale badge's email cell alignment again
The round-one rewrite dropped the cell assertions, which matter for
email clients that ignore auto margins.
* Count catalog-hidden grouped items in the email price
is_visible() also drops items hidden from the catalog, which merchants
often do for grouped items, so those emails showed no price. Only
unpublished items are left out now, as in the shop.
* Compare variable product prices as numbers in emails
A woocommerce_variation_prices filter can return equal prices with
different types, and the exact comparison then dropped the
struck-through regular price of a product on sale.
* Keep generate_coupon() typed and check filter values in a new callback
Removing the parameter types broke any subclass that overrides
generate_coupon() with the old signature. The filter now calls
filter_coupon_code(), which checks the values and passes valid ones to
the unchanged generate_coupon().
* Test the registered coupon filter and pass codes to generate_coupon()
A test now goes through the filter that init() registers, so hooking
the typed method again would fail it. The callback passes the code on
instead of repeating generate_coupon()'s own check.
* Render no coupon when the final coupon code is 0
A filter after the generator returning 0 printed "0" as the coupon,
while generate_coupon() treats 0 as no code. Raised by Seun in review.
diff --git a/packages/php/email-editor/changelog/fix-email-coupon-generator-filter-input b/packages/php/email-editor/changelog/fix-email-coupon-generator-filter-input
new file mode 100644
index 00000000000..04608000565
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-email-coupon-generator-filter-input
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent block emails with a coupon from failing or showing a wrong code when another plugin changes the coupon code filter value.
diff --git a/packages/php/email-editor/changelog/fix-email-variable-product-single-price b/packages/php/email-editor/changelog/fix-email-variable-product-single-price
new file mode 100644
index 00000000000..4696701808f
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-email-variable-product-single-price
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show the right prices in block emails for grouped products, variable products on sale, and unpriced products.
diff --git a/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-coupon-code.php b/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-coupon-code.php
index 126d15b2eab..f35625646f4 100644
--- a/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-coupon-code.php
+++ b/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-coupon-code.php
@@ -58,7 +58,9 @@ class Coupon_Code extends Abstract_Block_Renderer {
$rendering_context
);
- if ( empty( $coupon_code ) ) {
+ $coupon_code = is_scalar( $coupon_code ) ? (string) $coupon_code : '';
+ // "0" means no code, as in Coupon_Code_Generator::generate_coupon().
+ if ( '' === $coupon_code || '0' === $coupon_code ) {
return '';
}
diff --git a/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-price.php b/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-price.php
index 9196d03268b..63030493191 100644
--- a/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-price.php
+++ b/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-price.php
@@ -121,17 +121,9 @@ class Product_Price extends Abstract_Product_Block_Renderer {
}
if ( $product->is_on_sale() && '' !== $sale_price ) {
- return sprintf(
- '<del style="text-decoration: line-through; font-size: 0.9em; margin-right: 0.5em;">%s</del><span>%s</span>',
- wc_price( $regular_price, array( 'in_span' => false ) ),
- wc_price( $sale_price, array( 'in_span' => false ) )
- );
- } else {
- return sprintf(
- '<span>%s</span>',
- wc_price( $regular_price, array( 'in_span' => false ) )
- );
+ return $this->format_sale_price( (float) $regular_price, (float) $sale_price );
}
+ return $this->format_price( (float) $regular_price );
}
/**
@@ -145,11 +137,19 @@ class Product_Price extends Abstract_Product_Block_Renderer {
$min_price = $product->get_variation_price( 'min', true );
$max_price = $product->get_variation_price( 'max', true );
- return sprintf(
- '<span>%s — %s</span>',
- wc_price( (float) $min_price, array( 'in_span' => false ) ),
- wc_price( (float) $max_price, array( 'in_span' => false ) )
- );
+ if ( '' === $min_price || false === $min_price ) {
+ return '';
+ }
+
+ $min_regular_price = $product->get_variation_regular_price( 'min', true );
+ $max_regular_price = $product->get_variation_regular_price( 'max', true );
+ $one_price = is_numeric( $max_price ) && (float) $min_price === (float) $max_price;
+ $one_regular_price = is_numeric( $min_regular_price ) && is_numeric( $max_regular_price ) && (float) $min_regular_price === (float) $max_regular_price;
+ if ( $product->is_on_sale() && $one_price && $one_regular_price ) {
+ return $this->format_sale_price( (float) $min_regular_price, (float) $min_price );
+ }
+
+ return $this->format_price_range( (float) $min_price, (float) $max_price );
}
/**
@@ -159,16 +159,10 @@ class Product_Price extends Abstract_Product_Block_Renderer {
* @return string
*/
private function build_grouped_product_price( \WC_Product_Grouped $product ): string {
- $children = $product->get_children();
-
- if ( empty( $children ) ) {
- return '';
- }
-
$prices = array();
- foreach ( $children as $child_id ) {
+ foreach ( $product->get_children() as $child_id ) {
$child = wc_get_product( $child_id );
- if ( $child && $child->get_price() !== '' ) {
+ if ( $child && 'publish' === $child->get_status() && $child->get_price() !== '' ) {
$prices[] = wc_get_price_to_display( $child, array( 'price' => (float) $child->get_price() ) );
}
}
@@ -177,13 +171,50 @@ class Product_Price extends Abstract_Product_Block_Renderer {
return '';
}
- $min_price = min( $prices );
- $max_price = max( $prices );
+ return $this->format_price_range( (float) min( $prices ), (float) max( $prices ) );
+ }
+
+ /**
+ * Format a regular price struck through, followed by the sale price.
+ *
+ * @param float $regular_price Regular price.
+ * @param float $sale_price Sale price.
+ * @return string
+ */
+ private function format_sale_price( float $regular_price, float $sale_price ): string {
+ return sprintf(
+ '<del style="text-decoration: line-through; font-size: 0.9em; margin-right: 0.5em;">%s</del><span>%s</span>',
+ wc_price( $regular_price, array( 'in_span' => false ) ),
+ wc_price( $sale_price, array( 'in_span' => false ) )
+ );
+ }
+
+ /**
+ * Format a single price.
+ *
+ * @param float $price Price.
+ * @return string
+ */
+ private function format_price( float $price ): string {
+ return sprintf( '<span>%s</span>', wc_price( $price, array( 'in_span' => false ) ) );
+ }
+
+ /**
+ * Format a price range, or a single price when both ends are equal, as the shop does.
+ *
+ * @param float $min_price Lowest price.
+ * @param float $max_price Highest price.
+ * @return string
+ */
+ private function format_price_range( float $min_price, float $max_price ): string {
+ if ( $min_price === $max_price ) {
+ return $this->format_price( $min_price );
+ }
return sprintf(
'<span>%s — %s</span>',
- wc_price( (float) $min_price, array( 'in_span' => false ) ),
- wc_price( (float) $max_price, array( 'in_span' => false ) )
+ wc_price( $min_price, array( 'in_span' => false ) ),
+ wc_price( $max_price, array( 'in_span' => false ) )
);
}
diff --git a/packages/php/email-editor/src/Integrations/WooCommerce/class-coupon-code-generator.php b/packages/php/email-editor/src/Integrations/WooCommerce/class-coupon-code-generator.php
index 006e60cb7c0..d0d35d43a3c 100644
--- a/packages/php/email-editor/src/Integrations/WooCommerce/class-coupon-code-generator.php
+++ b/packages/php/email-editor/src/Integrations/WooCommerce/class-coupon-code-generator.php
@@ -32,7 +32,24 @@ class Coupon_Code_Generator {
* Initialize the generator by registering the filter hook.
*/
public function init(): void {
- add_filter( 'woocommerce_coupon_code_block_auto_generate', array( $this, 'generate_coupon' ), 10, 3 );
+ add_filter( 'woocommerce_coupon_code_block_auto_generate', array( $this, 'filter_coupon_code' ), 10, 3 );
+ }
+
+ /**
+ * Filter callback: checks the types of the filter's arguments, then generates a coupon.
+ *
+ * @param mixed $coupon_code The coupon code (empty if not yet generated).
+ * @param mixed $attrs Block attributes.
+ * @param mixed $rendering_context The rendering context.
+ * @return string The coupon code, or empty string on failure.
+ */
+ public function filter_coupon_code( $coupon_code, $attrs, $rendering_context ): string {
+ $coupon_code = is_scalar( $coupon_code ) ? (string) $coupon_code : '';
+ if ( ! $rendering_context instanceof Rendering_Context ) {
+ return $coupon_code;
+ }
+
+ return $this->generate_coupon( $coupon_code, is_array( $attrs ) ? $attrs : array(), $rendering_context );
}
/**
diff --git a/plugins/woocommerce/changelog/email-editor-product-block-tests b/plugins/woocommerce/changelog/email-editor-product-block-tests
new file mode 100644
index 00000000000..489cd0a9a12
--- /dev/null
+++ b/plugins/woocommerce/changelog/email-editor-product-block-tests
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Add tests for email editor product blocks and the coupon generator; no production change.
+
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/CouponCodeGeneratorTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/CouponCodeGeneratorTest.php
new file mode 100644
index 00000000000..69d2f860b8d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/CouponCodeGeneratorTest.php
@@ -0,0 +1,168 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Coupon_Code_Generator;
+use Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Renderer\Blocks\Coupon_Code;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Coupon_Code_Generator class, which creates the coupons sent in emails.
+ */
+class CouponCodeGeneratorTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var Coupon_Code_Generator
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new Coupon_Code_Generator();
+ }
+
+ /**
+ * @testdox Returns a code that another filter already provided.
+ */
+ public function test_returns_existing_code(): void {
+ $this->assertSame( 'GIVEN', $this->sut->generate_coupon( 'GIVEN', array(), $this->context() ) );
+ }
+
+ /**
+ * @testdox Generates a code when an earlier filter returned something other than a string.
+ */
+ public function test_non_string_code_from_filter_generates_a_code(): void {
+ $code = $this->sut->filter_coupon_code( null, 'not-an-array', $this->context() );
+
+ $this->assertNotSame( 0, wc_get_coupon_id_by_code( $code ) );
+ }
+
+ /**
+ * @testdox Handles a non-string code on the filter it registers.
+ */
+ public function test_registered_filter_accepts_non_string_code(): void {
+ remove_all_filters( 'woocommerce_coupon_code_block_auto_generate' );
+ $this->sut->init();
+
+ $code = apply_filters( 'woocommerce_coupon_code_block_auto_generate', null, array(), $this->context() );
+
+ $this->assertNotSame( 0, wc_get_coupon_id_by_code( $code ) );
+ }
+
+ /**
+ * @testdox Keeps a numeric code from an earlier filter as a string.
+ */
+ public function test_keeps_numeric_code(): void {
+ $this->assertSame( '123', $this->sut->filter_coupon_code( 123, array(), $this->context() ) );
+ }
+
+ /**
+ * @testdox Returns no code when the filter gets no rendering context.
+ */
+ public function test_without_rendering_context_returns_empty(): void {
+ $this->assertSame( '', $this->sut->filter_coupon_code( '', array(), null ) );
+ }
+
+ /**
+ * @testdox Returns the placeholder in a preview and creates no coupon.
+ */
+ public function test_preview_returns_placeholder(): void {
+ $code = $this->sut->generate_coupon( '', array(), $this->context( array( 'is_user_preview' => true ) ) );
+
+ $this->assertSame( Coupon_Code::COUPON_CODE_PLACEHOLDER, $code );
+ $this->assertSame( 0, wc_get_coupon_id_by_code( $code ) );
+ }
+
+ /**
+ * @testdox Creates a coupon with the settings from the block.
+ */
+ public function test_creates_coupon_from_block_settings(): void {
+ $code = $this->sut->generate_coupon(
+ '',
+ array(
+ 'discountType' => 'fixed_cart',
+ 'amount' => '15',
+ 'expiryDay' => 7,
+ 'freeShipping' => true,
+ 'minimumAmount' => '50',
+ 'individualUse' => true,
+ 'usageLimit' => '1',
+ 'usageLimitPerUser' => 'many',
+ 'productIds' => array( array( 'id' => 12 ), 'not-an-item' ),
+ ),
+ $this->context()
+ );
+
+ $this->assertMatchesRegularExpression( '/^[a-z0-9]{4}-[a-z0-9]{6}-[a-z0-9]{4}$/i', $code );
+ $coupon = new \WC_Coupon( $code );
+ $this->assertSame( 'fixed_cart', $coupon->get_discount_type() );
+ $this->assertSame( 15.0, (float) $coupon->get_amount() );
+ $this->assertTrue( $coupon->get_free_shipping() );
+ $this->assertSame( 50.0, (float) $coupon->get_minimum_amount() );
+ $this->assertTrue( $coupon->get_individual_use() );
+ $this->assertSame( 1, $coupon->get_usage_limit() );
+ $this->assertSame( 0, $coupon->get_usage_limit_per_user(), 'A non-numeric limit means no limit.' );
+ $this->assertSame( array( 12 ), $coupon->get_product_ids(), 'Items without an ID are dropped.' );
+ $this->assertEqualsWithDelta( time() + 7 * DAY_IN_SECONDS, $coupon->get_date_expires()->getTimestamp(), 60 );
+ }
+
+ /**
+ * @testdox Uses a percentage discount when the block's discount type is unknown.
+ */
+ public function test_unknown_discount_type_becomes_percent(): void {
+ $code = $this->sut->generate_coupon( '', array( 'discountType' => 'free_money' ), $this->context() );
+
+ $this->assertSame( 'percent', ( new \WC_Coupon( $code ) )->get_discount_type() );
+ }
+
+ /**
+ * @testdox Limits the coupon to valid listed emails and to the recipient.
+ */
+ public function test_restricts_coupon_to_valid_emails_and_recipient(): void {
+ $code = $this->sut->generate_coupon(
+ '',
+ array( 'emailRestrictions' => 'one@example.com, not-an-email ,two@example.com' ),
+ $this->context( array( 'recipient_email' => 'customer@example.com' ) )
+ );
+
+ $this->assertSame(
+ array( 'one@example.com', 'two@example.com', 'customer@example.com' ),
+ array_values( ( new \WC_Coupon( $code ) )->get_email_restrictions() )
+ );
+ }
+
+ /**
+ * @testdox Returns no code and creates no coupon when every generated code is taken.
+ */
+ public function test_returns_empty_when_no_unique_code_is_found(): void {
+ $existing = new \WC_Coupon();
+ $existing->set_code( 'TAKEN' );
+ $existing->save();
+ add_filter(
+ 'woocommerce_get_coupon_id_from_code',
+ function () use ( $existing ) {
+ return $existing->get_id();
+ }
+ );
+ $coupons_before = wp_count_posts( 'shop_coupon' )->publish;
+
+ $this->assertSame( '', $this->sut->generate_coupon( '', array(), $this->context() ) );
+ $this->assertSame( $coupons_before, wp_count_posts( 'shop_coupon' )->publish );
+ }
+
+ /**
+ * Rendering context.
+ *
+ * @param array $email_context Email context values.
+ */
+ private function context( array $email_context = array() ): Rendering_Context {
+ return new Rendering_Context( new \WP_Theme_JSON(), $email_context );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/CouponCodeTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/CouponCodeTest.php
new file mode 100644
index 00000000000..162ea6fc92e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/CouponCodeTest.php
@@ -0,0 +1,160 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\Renderer\Blocks;
+
+use Automattic\WooCommerce\EmailEditor\Bootstrap;
+use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Renderer\Blocks\Coupon_Code;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Coupon_Code email block renderer.
+ */
+class CouponCodeTest extends WC_Unit_Test_Case {
+
+ /**
+ * Saved block markup with the placeholder the editor stores.
+ */
+ private const BLOCK_CONTENT = '<div class="coupon">' . Coupon_Code::COUPON_CODE_PLACEHOLDER . '</div>';
+
+ /**
+ * The System Under Test.
+ *
+ * @var Coupon_Code
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new Coupon_Code();
+ }
+
+ /**
+ * @testdox Puts the generated code, escaped, in place of the placeholder.
+ */
+ public function test_generated_code_replaces_placeholder(): void {
+ $this->generate_code( 'SAVE<10>' );
+
+ $html = $this->render( array() );
+
+ $this->assertStringContainsString( '<div class="coupon">SAVE<10></div>', $html );
+ $this->assertStringNotContainsString( Coupon_Code::COUPON_CODE_PLACEHOLDER, $html );
+ }
+
+ /**
+ * @testdox Renders no coupon when a new code could not be generated.
+ */
+ public function test_no_generated_code_renders_no_coupon(): void {
+ $this->generate_code( '' );
+
+ $this->assertStringNotContainsString( 'class="coupon"', $this->render( array() ) );
+ }
+
+ /**
+ * @testdox Keeps an existing coupon code without generating one.
+ */
+ public function test_existing_coupon_is_kept(): void {
+ $generated = false;
+ add_filter(
+ 'woocommerce_coupon_code_block_auto_generate',
+ function ( $code ) use ( &$generated ) {
+ $generated = true;
+ return $code;
+ }
+ );
+
+ $html = $this->sut->render( '<div class="coupon">SUMMER</div>', $this->parsed_block( array( 'source' => 'existing' ) ), $this->context() );
+
+ $this->assertStringContainsString( '<div class="coupon">SUMMER</div>', $html );
+ $this->assertFalse( $generated, 'No coupon should be generated for an existing coupon.' );
+ }
+
+ /**
+ * @testdox Puts a coupon from WooCommerce's generator into a sent email.
+ */
+ public function test_woocommerce_generator_fills_the_code(): void {
+ // Starts the email editor as WooCommerce does when the block email editor feature is on.
+ Email_Editor_Container::container()->get( Bootstrap::class )->init();
+
+ $html = $this->render( array() );
+
+ $this->assertMatchesRegularExpression( '/<div class="coupon">[a-z0-9]{4}-[a-z0-9]{6}-[a-z0-9]{4}<\/div>/i', $html );
+ }
+
+ /**
+ * @testdox Renders no coupon when a filter returns something other than a code.
+ * @testWith [["not", "a", "code"]]
+ * [0]
+ * ["0"]
+ *
+ * @param mixed $code Value the filter returns.
+ */
+ public function test_non_code_value_renders_no_coupon( $code ): void {
+ $this->generate_code( $code );
+
+ $this->assertStringNotContainsString( 'class="coupon"', $this->render( array() ) );
+ }
+
+ /**
+ * @testdox Aligns the coupon as set, and centers it for any other value.
+ * @testWith ["left", "left"]
+ * ["right", "right"]
+ * ["justify", "center"]
+ *
+ * @param string $align Block alignment.
+ * @param string $expected Alignment used in the email.
+ */
+ public function test_alignment( string $align, string $expected ): void {
+ $this->generate_code( 'CODE' );
+
+ $this->assertStringContainsString( 'align="' . $expected . '" style="text-align:' . $expected . ';"', $this->render( array( 'align' => $align ) ) );
+ }
+
+ /**
+ * Makes code generation return a fixed value.
+ *
+ * @param mixed $code Generated code.
+ */
+ private function generate_code( $code ): void {
+ remove_all_filters( 'woocommerce_coupon_code_block_auto_generate' );
+ add_filter(
+ 'woocommerce_coupon_code_block_auto_generate',
+ function () use ( $code ) {
+ return $code;
+ }
+ );
+ }
+
+ /**
+ * Renders a block that creates a new coupon.
+ *
+ * @param array $attrs Block attributes.
+ */
+ private function render( array $attrs ): string {
+ return $this->sut->render( self::BLOCK_CONTENT, $this->parsed_block( $attrs ), $this->context() );
+ }
+
+ /**
+ * Parsed coupon code block.
+ *
+ * @param array $attrs Block attributes.
+ */
+ private function parsed_block( array $attrs ): array {
+ return array(
+ 'blockName' => 'woocommerce/coupon-code',
+ 'attrs' => $attrs,
+ );
+ }
+
+ /**
+ * Rendering context for a sent email.
+ */
+ private function context(): Rendering_Context {
+ return new Rendering_Context( new \WP_Theme_JSON() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/ProductButtonTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/ProductButtonTest.php
new file mode 100644
index 00000000000..85d607cb374
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/ProductButtonTest.php
@@ -0,0 +1,111 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\Renderer\Blocks;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Renderer\Blocks\Product_Button;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Product_Button email block renderer.
+ */
+class ProductButtonTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var Product_Button
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new Product_Button();
+ }
+
+ /**
+ * @testdox Links a simple product's button to the product page with its add-to-cart text.
+ */
+ public function test_simple_product_links_to_product_page(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $html = $this->render( $product->get_id() );
+
+ $this->assertStringContainsString( 'href="' . esc_url( $product->get_permalink() ) . '"', $html );
+ $this->assertStringContainsString( '>Add to cart</a>', $html );
+ }
+
+ /**
+ * @testdox Falls back to "Add to cart" when the product has no add-to-cart text.
+ */
+ public function test_empty_add_to_cart_text_falls_back(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ add_filter( 'woocommerce_product_add_to_cart_text', '__return_empty_string' );
+
+ $this->assertStringContainsString( '>Add to cart</a>', $this->render( $product->get_id() ) );
+ }
+
+ /**
+ * @testdox Links to the cart with "Finish checkout" in a cart contents collection.
+ */
+ public function test_cart_contents_collection_links_to_cart(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $html = $this->render( $product->get_id(), 'woocommerce/product-collection/cart-contents' );
+
+ $this->assertStringContainsString( 'href="' . esc_url( wc_get_cart_url() ) . '"', $html );
+ $this->assertStringContainsString( '>Finish checkout</a>', $html );
+ }
+
+ /**
+ * @testdox Links an external product to its external URL, escaped.
+ */
+ public function test_external_product_links_to_external_url(): void {
+ $product = WC_Helper_Product::create_external_product();
+ $product->set_product_url( 'https://example.com/buy?a=1&b=2' );
+ $product->save();
+
+ $this->assertStringContainsString( 'href="https://example.com/buy?a=1&b=2"', $this->render( $product->get_id() ) );
+ }
+
+ /**
+ * @testdox Links an external product without a URL to its product page.
+ */
+ public function test_external_product_without_url_links_to_product_page(): void {
+ $product = WC_Helper_Product::create_external_product();
+ $product->set_product_url( '' );
+ $product->save();
+
+ $this->assertStringContainsString( 'href="' . esc_url( $product->get_permalink() ) . '"', $this->render( $product->get_id() ) );
+ }
+
+ /**
+ * @testdox Renders no button when there is no product in the block context.
+ */
+ public function test_without_product_renders_no_button(): void {
+ $this->assertStringNotContainsString( '<a ', $this->render( 0 ) );
+ }
+
+ /**
+ * Renders the block for a product.
+ *
+ * @param int $product_id Product ID in the block context.
+ * @param string $collection Product collection the block is in.
+ */
+ private function render( int $product_id, string $collection = '' ): string {
+ $parsed_block = array(
+ 'blockName' => 'woocommerce/product-button',
+ 'attrs' => array(),
+ 'context' => array(
+ 'postId' => $product_id,
+ 'collection' => $collection,
+ ),
+ );
+ return $this->sut->render( '', $parsed_block, new Rendering_Context( new \WP_Theme_JSON() ) );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/ProductPriceTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/ProductPriceTest.php
new file mode 100644
index 00000000000..47d549b4dd4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/ProductPriceTest.php
@@ -0,0 +1,268 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\Renderer\Blocks;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Renderer\Blocks\Product_Price;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Product_Price email block renderer.
+ */
+class ProductPriceTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var Product_Price
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new Product_Price();
+ }
+
+ /**
+ * @testdox Shows the regular price of a simple product that is not on sale.
+ */
+ public function test_simple_product_shows_regular_price(): void {
+ $product = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => 12 ) );
+
+ $html = $this->render( $product->get_id() );
+
+ $this->assertSame( '$12.00', $this->text( $html ) );
+ $this->assertStringNotContainsString( '<del', $html );
+ }
+
+ /**
+ * @testdox Shows the regular price struck through, followed by the sale price.
+ */
+ public function test_product_on_sale_shows_both_prices(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'regular_price' => 12,
+ 'sale_price' => 9,
+ )
+ );
+
+ $html = $this->render( $product->get_id() );
+
+ $this->assertMatchesRegularExpression( '/<del[^>]*>.*12\.00.*<\/del><span>.*9\.00.*<\/span>/s', $html );
+ }
+
+ /**
+ * @testdox Shows no price for a product without a regular price.
+ */
+ public function test_product_without_regular_price_renders_nothing(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'regular_price' => '',
+ 'price' => '',
+ )
+ );
+
+ $this->assertSame( '', $this->text( $this->render( $product->get_id() ) ) );
+ }
+
+ /**
+ * @testdox Shows no price when there is no product in the block context.
+ */
+ public function test_without_product_renders_nothing(): void {
+ $this->assertSame( '', $this->text( $this->render( 0 ) ) );
+ }
+
+ /**
+ * @testdox Shows the price range of a variable product.
+ */
+ public function test_variable_product_shows_price_range(): void {
+ $product = $this->create_variable_product( array( 10, 20 ) );
+
+ $this->assertSame( '$10.00 — $20.00', $this->text( $this->render( $product->get_id() ) ) );
+ }
+
+ /**
+ * @testdox Shows one price for a variable product whose variations all cost the same.
+ */
+ public function test_variable_product_with_one_price_shows_it_once(): void {
+ $product = $this->create_variable_product( array( 15, 15 ) );
+
+ $this->assertSame( '$15.00', $this->text( $this->render( $product->get_id() ) ) );
+ }
+
+ /**
+ * @testdox Shows the regular price struck through for a variable product on sale at one price.
+ */
+ public function test_variable_product_on_sale_at_one_price_shows_both_prices(): void {
+ $product = $this->create_variable_product( array( 20, 20 ), 15 );
+
+ $this->assertMatchesRegularExpression( '/<del[^>]*>.*20\.00.*<\/del><span>.*15\.00.*<\/span>/s', $this->render( $product->get_id() ) );
+ }
+
+ /**
+ * @testdox Still shows the struck-through price when a filter returns equal prices of different types.
+ */
+ public function test_variable_product_on_sale_with_mixed_price_types(): void {
+ $product = $this->create_variable_product( array( 20, 20 ), 15 );
+ add_filter(
+ 'woocommerce_variation_prices',
+ function ( $prices ) {
+ foreach ( array( 'price', 'regular_price', 'sale_price' ) as $key ) {
+ $ids = array_keys( $prices[ $key ] );
+ $prices[ $key ][ $ids[0] ] = (float) $prices[ $key ][ $ids[0] ];
+ }
+ return $prices;
+ }
+ );
+
+ $this->assertMatchesRegularExpression( '/<del[^>]*>.*20\.00.*<\/del><span>.*15\.00.*<\/span>/s', $this->render( $product->get_id() ) );
+ }
+
+ /**
+ * @testdox Shows no price for a variable product whose variations have no price.
+ */
+ public function test_variable_product_without_prices_shows_no_price(): void {
+ $product = $this->create_variable_product( array( '', '' ) );
+
+ $this->assertSame( '', $this->text( $this->render( $product->get_id() ) ) );
+ }
+
+ /**
+ * @testdox Shows one price for a grouped product whose items all cost the same.
+ */
+ public function test_grouped_product_with_one_price_shows_it_once(): void {
+ $grouped = new \WC_Product_Grouped();
+ $grouped->set_children(
+ array(
+ WC_Helper_Product::create_simple_product()->get_id(),
+ WC_Helper_Product::create_simple_product()->get_id(),
+ )
+ );
+ $grouped->save();
+
+ $this->assertSame( '$10.00', $this->text( $this->render( $grouped->get_id() ) ) );
+ }
+
+ /**
+ * @testdox Shows the range of child prices for a grouped product.
+ */
+ public function test_grouped_product_shows_child_price_range(): void {
+ $product = WC_Helper_Product::create_grouped_product();
+
+ $this->assertSame( '$1.00 — $10.00', $this->text( $this->render( $product->get_id() ) ) );
+ }
+
+ /**
+ * @testdox Shows no price for a grouped product with no children, or none with a price.
+ */
+ public function test_grouped_product_without_priced_children_renders_nothing(): void {
+ $grouped = new \WC_Product_Grouped();
+ $grouped->save();
+ $this->assertSame( '', $this->text( $this->render( $grouped->get_id() ) ), 'No children' );
+
+ $child = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'regular_price' => '',
+ 'price' => '',
+ )
+ );
+ $grouped->set_children( array( $child->get_id() ) );
+ $grouped->save();
+ $this->assertSame( '', $this->text( $this->render( $grouped->get_id() ) ), 'Children without a price' );
+ }
+
+ /**
+ * @testdox Leaves items that are not published out of a grouped product's price range.
+ */
+ public function test_grouped_product_ignores_unpublished_items(): void {
+ $draft = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'regular_price' => 1,
+ 'price' => 1,
+ 'status' => 'draft',
+ )
+ );
+ $grouped = new \WC_Product_Grouped();
+ $grouped->set_children( array( $draft->get_id(), WC_Helper_Product::create_simple_product()->get_id() ) );
+ $grouped->save();
+
+ $this->assertSame( '$10.00', $this->text( $this->render( $grouped->get_id() ) ) );
+ }
+
+ /**
+ * @testdox Counts grouped items that are hidden from the catalog, as the shop does.
+ */
+ public function test_grouped_product_counts_catalog_hidden_items(): void {
+ $cheap = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'regular_price' => 5,
+ 'price' => 5,
+ 'catalog_visibility' => 'hidden',
+ )
+ );
+ $dear = WC_Helper_Product::create_simple_product( true, array( 'catalog_visibility' => 'hidden' ) );
+ $grouped = new \WC_Product_Grouped();
+ $grouped->set_children( array( $cheap->get_id(), $dear->get_id() ) );
+ $grouped->save();
+
+ $this->assertSame( '$5.00 — $10.00', $this->text( $this->render( $grouped->get_id() ) ) );
+ }
+
+ /**
+ * Renders the block for a product.
+ *
+ * @param int $product_id Product ID in the block context.
+ */
+ private function render( int $product_id ): string {
+ $parsed_block = array(
+ 'blockName' => 'woocommerce/product-price',
+ 'attrs' => array(),
+ 'context' => array( 'postId' => $product_id ),
+ );
+ return $this->sut->render( '', $parsed_block, new Rendering_Context( new \WP_Theme_JSON() ) );
+ }
+
+ /**
+ * The text a reader sees in the rendered HTML.
+ *
+ * @param string $html Rendered HTML.
+ */
+ private function text( string $html ): string {
+ return trim( html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES ) );
+ }
+
+ /**
+ * Creates a variable product with one variation per price.
+ *
+ * @param array $prices Variation regular prices.
+ * @param int|null $sale_price Sale price for every variation.
+ */
+ private function create_variable_product( array $prices, ?int $sale_price = null ): \WC_Product_Variable {
+ $attribute = WC_Helper_Product::create_product_attribute_object( 'size', array( 'small', 'large' ) );
+ $product = new \WC_Product_Variable();
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $sizes = array( 'small', 'large' );
+ foreach ( $prices as $index => $price ) {
+ $variation = WC_Helper_Product::create_product_variation_object( $product->get_id(), 'SKU-' . $product->get_id() . '-' . $index, $price, array( 'pa_size' => $sizes[ $index ] ) );
+ if ( null !== $sale_price ) {
+ $variation->set_sale_price( (string) $sale_price );
+ $variation->save();
+ }
+ }
+ \WC_Product_Variable::sync( $product->get_id() );
+
+ return wc_get_product( $product->get_id() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/ProductSaleBadgeTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/ProductSaleBadgeTest.php
new file mode 100644
index 00000000000..7c08e349e43
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/ProductSaleBadgeTest.php
@@ -0,0 +1,113 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\Renderer\Blocks;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Renderer\Blocks\Product_Sale_Badge;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Product_Sale_Badge email block renderer.
+ */
+class ProductSaleBadgeTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var Product_Sale_Badge
+ */
+ private $sut;
+
+ /**
+ * Product on sale.
+ *
+ * @var \WC_Product
+ */
+ private $product;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new Product_Sale_Badge();
+ $this->product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'regular_price' => 12,
+ 'sale_price' => 9,
+ )
+ );
+ }
+
+ /**
+ * @testdox Shows the sale badge for a product on sale.
+ */
+ public function test_product_on_sale_shows_badge(): void {
+ $this->assertStringContainsString( '>Sale</span>', $this->render( $this->product->get_id() ) );
+ }
+
+ /**
+ * @testdox Shows no badge for a product that is not on sale.
+ */
+ public function test_product_not_on_sale_shows_no_badge(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $this->assertStringNotContainsString( 'sale-badge__text', $this->render( $product->get_id() ) );
+ }
+
+ /**
+ * @testdox Uses the badge text from the woocommerce_sale_badge_text filter, escaped.
+ */
+ public function test_badge_text_comes_from_filter_and_is_escaped(): void {
+ add_filter(
+ 'woocommerce_sale_badge_text',
+ function () {
+ return '<b>Deal</b>';
+ }
+ );
+
+ $html = $this->render( $this->product->get_id() );
+
+ $this->assertStringContainsString( '<b>Deal</b>', $html );
+ $this->assertStringNotContainsString( '<b>Deal</b>', $html );
+ }
+
+ /**
+ * @testdox Places the badge and its email cell according to the alignment, and on the default side for any other value.
+ * @testWith ["left", "text-align:left;", "margin-right:auto;", "left"]
+ * ["center", "text-align:center;", "margin-left:auto;margin-right:auto;", "center"]
+ * ["right", "text-align:right;", "margin-left:auto;", "right"]
+ * ["justify", "text-align:left;", "margin-right:auto;", "left"]
+ *
+ * @param string $align Block alignment.
+ * @param string $text_align Text alignment of the badge.
+ * @param string $margins Margins that position the badge.
+ * @param string $cell_align Alignment of the email table cell around the badge.
+ */
+ public function test_badge_follows_alignment( string $align, string $text_align, string $margins, string $cell_align ): void {
+ $html = $this->render( $this->product->get_id(), array( 'align' => $align ) );
+
+ $this->assertSame( 1, preg_match( '/<span class="wc-block-components-product-sale-badge__text" style="([^"]*)"/', $html, $matches ) );
+ $this->assertStringContainsString( $text_align, $matches[1] );
+ $this->assertStringEndsWith( 'position:static;' . $margins, $matches[1] );
+ $this->assertMatchesRegularExpression( '/class="email-product-sale-badge-cell"[^>]*align="' . $cell_align . '"/', $html );
+ }
+
+ /**
+ * Renders the block for a product.
+ *
+ * @param int $product_id Product ID in the block context.
+ * @param array $attrs Block attributes.
+ */
+ private function render( int $product_id, array $attrs = array() ): string {
+ $parsed_block = array(
+ 'blockName' => 'woocommerce/product-sale-badge',
+ 'attrs' => $attrs,
+ 'context' => array( 'postId' => $product_id ),
+ );
+ return $this->sut->render( '', $parsed_block, new Rendering_Context( new \WP_Theme_JSON() ) );
+ }
+}