Commit b61f1ebede4 for woocommerce

commit b61f1ebede410133233aeb991a1e34c90365f6a7
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date:   Fri Nov 13 07:25:56 2026 +0100

    Test the email editor's PHP renderers, tags and MailPoet contract (#70856)

    * Show the shop's products in email product collections

    Email collections showed products hidden from the catalog, ignored
    exclusions on hand-picked products, and picked on-sale products by
    the sale price field, which keeps ended sales and misses variable
    products. They now follow the shop's Product Collection rules.

    * Test the block email context and its fallback when rendering fails

    Covers the recipient and order customer handed to blocks, and that a
    failed render falls back to the classic email without leaving its
    context filter behind.

    * Test the customer, store and site tags and decode quotes in the site title

    The tags pick values from the order, the user or the email, and only
    the order tags had tests. The site title kept apostrophes as &#039; on
    PHP 7.4, where htmlspecialchars_decode() leaves single quotes encoded
    by default.

    * Test the email data validator

    The WooCommerce REST controller uses it to accept or reject saved
    email settings, and it had no tests. Covers the strict type checks it
    adds on top of WordPress, error paths, and anyOf/oneOf.

    * Test the email editor's minimum WordPress version check

    MailPoet calls Dependency_Check to decide whether to offer the block
    editor, and nothing tested where the line is.

    * Pin the parts of the email editor package that MailPoet uses

    A rename, a new required argument, a new interface method or a renamed
    hook in a minor release would break MailPoet with no failing test here.
    The test lists what MailPoet's PHP uses, taken from its code.

    * Test that the email editor starts only with its feature on

    Also covers the one decision in TemplatesController: the email
    template stays out of the Site Editor but is kept everywhere else.

    * Test the alignment order of the fallback block renderer

    Blocks without an email renderer take textAlign first, then align,
    then the email's default side; only the default had a test.

    * Keep the on-sale filter on related collections and show all cart items

    Upsell, cross-sell, related and cart collections replaced the on-sale
    limit with their own products, so full-price products came back. Cart
    reminders now show every product in the cart, also ones hidden from
    the catalog. Adds tests for category, attribute and offset filters.

    * Pin MailPoet's interface signatures and preview hook in the contract

    The contract test now compares full method signatures of the
    interfaces MailPoet implements and covers the preview personalizer
    context filter. Also splits a test array for phpcs and rewords the
    package changelog.

    * Tighten the CodeRabbit-flagged email editor tests

    The rendering failure test now proves the failure ran, the context
    test uses a bare order, and the fallback and collection tests read
    attributes and headings from parsed HTML instead of a regex.

    * Hide catalog-hidden products in email cart reminders, as the shop does

    The shop's cart block leaves out products hidden from the catalog, so
    cart reminder emails now do the same (Pavel's decision).

    * Restore the libxml error mode after parsing in the collection test

    The heading helper switched libxml to internal errors and left it so,
    which could hide warnings in later tests.

    * Show catalog-hidden products in email cart reminders again

    The shop's cart lists every cart item, hidden or not, and only drops
    the product link. Reminders now do the same. Raised by Rosta in review.

diff --git a/packages/php/email-editor/changelog/email-editor-php-test-coverage b/packages/php/email-editor/changelog/email-editor-php-test-coverage
new file mode 100644
index 00000000000..00946e7942f
--- /dev/null
+++ b/packages/php/email-editor/changelog/email-editor-php-test-coverage
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Add email editor package tests for the validator, version check, fallback renderer and MailPoet contract; no production change.
+
diff --git a/packages/php/email-editor/changelog/fix-email-product-collection-query b/packages/php/email-editor/changelog/fix-email-product-collection-query
new file mode 100644
index 00000000000..561ee57c97e
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-email-product-collection-query
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+In block email product collections, hide catalog-hidden products, apply exclusions to picked products, and use current sales.
diff --git a/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-collection.php b/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-collection.php
index e17fb9d2a73..9e581cd9f76 100644
--- a/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-collection.php
+++ b/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-collection.php
@@ -327,31 +327,36 @@ class Product_Collection extends Abstract_Product_Block_Renderer {
 			$query_args['orderby']  = 'post__in';
 		}

+		$visibility_terms = wc_get_product_visibility_term_ids();
+
 		// Handle featured products - use the WooCommerce way.
 		$is_featured = $query_attrs['featured'] ?? false;
 		if ( 'woocommerce/product-collection/featured' === $collection || $is_featured ) {
-			// Use WooCommerce's built-in function to get featured products query.
-			$featured_query = wc_get_product_visibility_term_ids();
-			if ( isset( $featured_query['featured'] ) ) {
+			if ( isset( $visibility_terms['featured'] ) ) {
 				$query_args['tax_query'][] = array(
 					'taxonomy' => 'product_visibility',
 					'field'    => 'term_taxonomy_id',
-					'terms'    => array( (int) $featured_query['featured'] ),
+					'terms'    => array( (int) $visibility_terms['featured'] ),
 					'operator' => 'IN',
 				);
 			}
 		}

-		// Handle on-sale products.
+		// Handle on-sale products the way the shop does: the sale must be running now, and variable
+		// products count when one of their variations is on sale.
 		$is_on_sale = $query_attrs['woocommerceOnSale'] ?? false;
 		if ( 'woocommerce/product-collection/on-sale' === $collection || $is_on_sale ) {
-			$query_args['meta_query'][] = array(
-				'relation' => 'OR',
-				array(
-					'key'     => '_sale_price',
-					'value'   => '',
-					'compare' => '!=',
-				),
+			$query_args['post__in'] = $this->restrict_post_ids( $query_args['post__in'] ?? null, wc_get_product_ids_on_sale() );
+		}
+
+		// Leave out products hidden from the shop catalog, as the shop's Product Collection does.
+		// Cart contents keep them: the shop's cart lists every cart item, hidden or not.
+		if ( 'woocommerce/product-collection/cart-contents' !== $collection && ! empty( $visibility_terms['exclude-from-catalog'] ) ) {
+			$query_args['tax_query'][] = array(
+				'taxonomy' => 'product_visibility',
+				'field'    => 'term_taxonomy_id',
+				'terms'    => array( (int) $visibility_terms['exclude-from-catalog'] ),
+				'operator' => 'NOT IN',
 			);
 		}

@@ -380,7 +385,13 @@ class Product_Collection extends Abstract_Product_Block_Renderer {
 		// Handle special collections: upsells, cross-sells, related, cart-contents.
 		$product_ids_to_include = $this->get_collection_specific_product_ids( $collection, $parsed_block, $rendering_context );
 		if ( ! empty( $product_ids_to_include ) ) {
-			$query_args['post__in'] = $product_ids_to_include;
+			// Keep earlier limits, such as the on-sale filter, that already set post__in.
+			$query_args['post__in'] = $this->restrict_post_ids( $query_args['post__in'] ?? null, $product_ids_to_include );
+		}
+
+		// WP_Query ignores post__not_in when post__in is set, so remove the exclusions from the list.
+		if ( ! empty( $query_args['post__in'] ) && ! empty( $query_args['post__not_in'] ) ) {
+			$query_args['post__in'] = $this->match_ids_or_nothing( array_diff( $query_args['post__in'], $query_args['post__not_in'] ) );
 		}

 		// Set tax_query relation if multiple tax queries.
@@ -394,6 +405,28 @@ class Product_Collection extends Abstract_Product_Block_Renderer {
 	}


+	/**
+	 * Limit a post__in list to the allowed IDs.
+	 *
+	 * @param array|null $current_ids Current post__in list, or null when there is none.
+	 * @param array      $allowed_ids IDs the query may return.
+	 * @return array
+	 */
+	private function restrict_post_ids( ?array $current_ids, array $allowed_ids ): array {
+		return $this->match_ids_or_nothing( null === $current_ids ? $allowed_ids : array_intersect( $current_ids, $allowed_ids ) );
+	}
+
+	/**
+	 * Turn a list of IDs into a post__in value.
+	 *
+	 * @param array $ids Product IDs.
+	 * @return array
+	 */
+	private function match_ids_or_nothing( array $ids ): array {
+		// An empty post__in means "no restriction" to WP_Query, so use 0 to match no product.
+		return empty( $ids ) ? array( 0 ) : array_values( array_map( 'intval', $ids ) );
+	}
+
 	/**
 	 * Check if all stock statuses are selected (meaning no filtering needed).
 	 *
diff --git a/packages/php/email-editor/tests/integration/Engine/Dependency_Check_Test.php b/packages/php/email-editor/tests/integration/Engine/Dependency_Check_Test.php
new file mode 100644
index 00000000000..90a7825eca8
--- /dev/null
+++ b/packages/php/email-editor/tests/integration/Engine/Dependency_Check_Test.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare(strict_types = 1);
+namespace Automattic\WooCommerce\EmailEditor\Engine;
+
+/**
+ * Integration test for Dependency_Check, which MailPoet calls to decide whether to offer the editor.
+ */
+class Dependency_Check_Test extends \Email_Editor_Integration_Test_Case {
+	/**
+	 * WordPress version before the test.
+	 *
+	 * @var string
+	 */
+	private string $original_wp_version;
+
+	/**
+	 * Set up before each test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->original_wp_version = get_bloginfo( 'version' );
+	}
+
+	/**
+	 * Restore the WordPress version.
+	 */
+	public function tearDown(): void {
+		$GLOBALS['wp_version'] = $this->original_wp_version; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restores the test's change.
+		parent::tearDown();
+	}
+
+	/**
+	 * Test the editor's dependencies are met from the minimum WordPress version on.
+	 *
+	 * @testWith ["6.6.2", false]
+	 *           ["6.7", true]
+	 *           ["7.1", true]
+	 *
+	 * @param string $wp_version WordPress version.
+	 * @param bool   $expected   Whether the dependencies are met.
+	 */
+	public function testItRequiresTheMinimumWordPressVersion( string $wp_version, bool $expected ): void {
+		$GLOBALS['wp_version'] = $wp_version; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- sets the version under test.
+
+		$this->assertSame( $expected, ( new Dependency_Check() )->are_dependencies_met() );
+	}
+}
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Fallback_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Fallback_Test.php
new file mode 100644
index 00000000000..7da7eab80eb
--- /dev/null
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Fallback_Test.php
@@ -0,0 +1,48 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare(strict_types = 1);
+namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+
+/**
+ * Integration test for the Fallback renderer, used for blocks without an email renderer.
+ */
+class Fallback_Test extends \Email_Editor_Integration_Test_Case {
+	/**
+	 * Test the block is aligned by textAlign first, then align, then the email default.
+	 *
+	 * @testWith [{"textAlign": "right", "align": "center"}, "right"]
+	 *           [{"textAlign": "justify", "align": "center"}, "center"]
+	 *           [{"align": "center"}, "center"]
+	 *           [{"align": "wide"}, "left"]
+	 *           [{}, "left"]
+	 *
+	 * @param array  $attrs    Block attributes.
+	 * @param string $expected Alignment in the email.
+	 */
+	public function testItAlignsTheBlockByTheFirstValidSetting( array $attrs, string $expected ): void {
+		$rendered = ( new Fallback() )->render(
+			'<div>Content</div>',
+			array(
+				'blockName' => 'test/block',
+				'attrs'     => $attrs,
+			),
+			new Rendering_Context( new \WP_Theme_JSON() )
+		);
+
+		// The content cell is the only table cell with an align attribute.
+		$processor = new \WP_HTML_Tag_Processor( $rendered );
+		$align     = null;
+		while ( null === $align && $processor->next_tag( array( 'tag_name' => 'td' ) ) ) {
+			$align = $processor->get_attribute( 'align' );
+		}
+		$this->assertSame( $expected, $align );
+		$this->assertStringContainsString( '<div>Content</div>', $rendered );
+	}
+}
diff --git a/packages/php/email-editor/tests/integration/Public_Api_Contract_Test.php b/packages/php/email-editor/tests/integration/Public_Api_Contract_Test.php
new file mode 100644
index 00000000000..440bea31bd3
--- /dev/null
+++ b/packages/php/email-editor/tests/integration/Public_Api_Contract_Test.php
@@ -0,0 +1,367 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare(strict_types = 1);
+namespace Automattic\WooCommerce\EmailEditor;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Dependency_Check;
+use Automattic\WooCommerce\EmailEditor\Engine\Email_Editor;
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger;
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface;
+use Automattic\WooCommerce\EmailEditor\Engine\Patterns\Abstract_Pattern;
+use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\HTML_Tag_Processor;
+use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
+use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
+use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Process_Manager;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\Css_Inliner;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\Html2Text;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\Renderer;
+use Automattic\WooCommerce\EmailEditor\Engine\Send_Preview_Email;
+use Automattic\WooCommerce\EmailEditor\Engine\Settings_Controller;
+use Automattic\WooCommerce\EmailEditor\Engine\Templates\Template;
+use Automattic\WooCommerce\EmailEditor\Engine\Templates\Templates_Registry;
+use Automattic\WooCommerce\EmailEditor\Engine\Theme_Controller;
+use Automattic\WooCommerce\EmailEditor\Engine\User_Theme;
+use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Column;
+use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Columns;
+use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Image;
+use Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Initializer as WooCommerce_Initializer;
+
+/**
+ * Pins the parts of this package that MailPoet uses: the services it takes from the container,
+ * the methods it calls with the argument counts it passes, the interfaces and base classes it
+ * builds on, and the hooks it listens to. A failure here means a change would break MailPoet.
+ *
+ * The list comes from MailPoet's code (free and Premium PHP). Update it when MailPoet starts or
+ * stops using a part of the package.
+ */
+class Public_Api_Contract_Test extends \Email_Editor_Integration_Test_Case {
+	/**
+	 * Test MailPoet can take each service it uses from the container.
+	 *
+	 * @dataProvider service_provider
+	 * @param class-string $service_class Service class.
+	 */
+	public function testContainerProvidesService( string $service_class ): void {
+		$this->assertInstanceOf( $service_class, Email_Editor_Container::container()->get( $service_class ) );
+	}
+
+	/**
+	 * Services MailPoet takes from the container.
+	 *
+	 * @return array<string, array{class-string}>
+	 */
+	public function service_provider(): array {
+		$services = array(
+			Bootstrap::class,
+			Email_Editor_Logger::class,
+			Personalizer::class,
+			Personalization_Tags_Registry::class,
+			Dependency_Check::class,
+			Settings_Controller::class,
+			Theme_Controller::class,
+			User_Theme::class,
+			Renderer::class,
+			Process_Manager::class,
+			WooCommerce_Initializer::class,
+		);
+		return array_combine( $services, array_map( fn( $service ) => array( $service ), $services ) );
+	}
+
+	/**
+	 * Test each method MailPoet calls exists, is public and accepts the arguments MailPoet passes.
+	 *
+	 * @dataProvider method_provider
+	 * @param string $class_name Class.
+	 * @param string $method     Method.
+	 * @param int    $arg_count  Number of arguments MailPoet passes.
+	 * @param bool   $is_static  Whether MailPoet calls it statically.
+	 */
+	public function testMethodAcceptsMailPoetCall( string $class_name, string $method, int $arg_count, bool $is_static = false ): void {
+		$this->assertTrue( method_exists( $class_name, $method ), "{$class_name}::{$method}() is missing." );
+		$reflection = new \ReflectionMethod( $class_name, $method );
+		$this->assertTrue( $reflection->isPublic(), "{$class_name}::{$method}() must stay public." );
+		$this->assertSame( $is_static, $reflection->isStatic(), "{$class_name}::{$method}() changed between static and instance." );
+		$this->assertLessThanOrEqual( $arg_count, $reflection->getNumberOfRequiredParameters(), "{$class_name}::{$method}() requires more arguments than MailPoet passes." );
+		$this->assertGreaterThanOrEqual( $arg_count, $reflection->getNumberOfParameters(), "{$class_name}::{$method}() accepts fewer arguments than MailPoet passes." );
+	}
+
+	/**
+	 * Methods MailPoet calls, with the most arguments it passes.
+	 *
+	 * @return array<string, array{0: string, 1: string, 2: int, 3?: bool}>
+	 */
+	public function method_provider(): array {
+		$methods = array(
+			array( Email_Editor_Container::class, 'init', 0, true ),
+			array( Email_Editor_Container::class, 'container', 0, true ),
+			array( Bootstrap::class, 'init', 0 ),
+			array( Email_Editor_Logger::class, 'set_logger', 1 ),
+			array( Dependency_Check::class, 'are_dependencies_met', 0 ),
+			array( Personalizer::class, 'set_context', 1 ),
+			array( Personalizer::class, 'personalize_content', 2 ),
+			array( Personalization_Tags_Registry::class, 'register', 1 ),
+			array( Personalization_Tags_Registry::class, 'unregister', 1 ),
+			array( Personalization_Tags_Registry::class, 'get_all', 0 ),
+			array( Personalization_Tags_Registry::class, 'get_by_token', 1 ),
+			array( Personalization_Tag::class, '__construct', 7 ),
+			array( Personalization_Tag::class, '__construct', 8 ),
+			array( Personalization_Tag::class, 'get_name', 0 ),
+			array( Personalization_Tag::class, 'get_token', 0 ),
+			array( Personalization_Tag::class, 'get_category', 0 ),
+			array( Personalization_Tag::class, 'get_callback', 0 ),
+			array( Personalization_Tag::class, 'get_attributes', 0 ),
+			array( Personalization_Tag::class, 'get_value_to_insert', 0 ),
+			array( Personalization_Tag::class, 'get_post_types', 0 ),
+			array( Personalization_Tag::class, 'get_value_type', 0 ),
+			array( Personalization_Tag::class, 'execute_callback', 1 ),
+			array( HTML_Tag_Processor::class, '__construct', 1 ),
+			array( HTML_Tag_Processor::class, 'replace_token', 1 ),
+			array( HTML_Tag_Processor::class, 'flush_updates', 0 ),
+			array( Html2Text::class, 'convert', 2, true ),
+			array( Renderer::class, 'render', 5 ),
+			array( Renderer::class, 'render', 6 ),
+			array( Rendering_Context::class, '__construct', 1 ),
+			array( Rendering_Context::class, 'get_email_context', 0 ),
+			array( Rendering_Context::class, 'get_recipient_email', 0 ),
+			array( Rendering_Context::class, 'get_theme_styles', 0 ),
+			array( Rendering_Context::class, 'get_layout_width_without_padding', 0 ),
+			array( Process_Manager::class, 'preprocess', 4 ),
+			array( Theme_Controller::class, 'get_base_theme', 0 ),
+			array( Theme_Controller::class, 'get_theme', 0 ),
+			array( Theme_Controller::class, 'get_styles', 0 ),
+			array( Theme_Controller::class, 'get_variables_values_map', 0 ),
+			array( Theme_Controller::class, 'get_layout_settings', 0 ),
+			array( Settings_Controller::class, 'get_settings', 0 ),
+			array( User_Theme::class, 'get_user_theme_post', 0 ),
+			array( Template::class, '__construct', 6 ),
+			array( Templates_Registry::class, 'register', 1 ),
+			array( Abstract_Pattern::class, 'get_name', 0 ),
+			array( Abstract_Pattern::class, 'get_namespace', 0 ),
+			array( Abstract_Pattern::class, 'get_properties', 0 ),
+			array( WooCommerce_Initializer::class, 'render_block', 3 ),
+			array( Image::class, 'render', 3 ),
+			array( Column::class, 'render', 3 ),
+			array( Columns::class, 'render', 3 ),
+		);
+		$cases   = array();
+		foreach ( $methods as $method ) {
+			$cases[ "{$method[0]}::{$method[1]}() with {$method[2]} arguments" ] = $method;
+		}
+		return $cases;
+	}
+
+	/**
+	 * Test the interfaces MailPoet implements keep exactly their method signatures. A new method,
+	 * or a new parameter or return type, would make MailPoet's classes fail to load.
+	 *
+	 * @dataProvider interface_provider
+	 * @param class-string          $interface_name Interface.
+	 * @param array<string, string> $signatures     Method name => parameters and return type.
+	 */
+	public function testInterfaceSignaturesStayTheSame( string $interface_name, array $signatures ): void {
+		$actual = array();
+		foreach ( ( new \ReflectionClass( $interface_name ) )->getMethods() as $method ) {
+			$parameters                   = array_map(
+				fn( \ReflectionParameter $parameter ) => trim( ( $parameter->getType() ?? '' ) . ' $' . $parameter->getName() . ( $parameter->isOptional() ? ' = ?' : '' ) ),
+				$method->getParameters()
+			);
+			$actual[ $method->getName() ] = '(' . implode( ', ', $parameters ) . '): ' . $method->getReturnType();
+		}
+		ksort( $actual );
+		ksort( $signatures );
+
+		$this->assertSame( $signatures, $actual );
+	}
+
+	/**
+	 * Interfaces MailPoet implements.
+	 *
+	 * @return array<string, array{class-string, array<string, string>}>
+	 */
+	public function interface_provider(): array {
+		$log = '(string $message, array $context = ?): void';
+		return array(
+			'logger'      => array(
+				Email_Editor_Logger_Interface::class,
+				array(
+					'alert'     => $log,
+					'critical'  => $log,
+					'debug'     => $log,
+					'emergency' => $log,
+					'error'     => $log,
+					'info'      => $log,
+					'log'       => '(string $level, string $message, array $context = ?): void',
+					'notice'    => $log,
+					'warning'   => $log,
+				),
+			),
+			'CSS inliner' => array(
+				Css_Inliner::class,
+				array(
+					'from_html'  => '(string $unprocessed_html): self',
+					'inline_css' => '(string $css = ?): self',
+					'render'     => '(): string',
+				),
+			),
+		);
+	}
+
+	/**
+	 * Test the pattern base class MailPoet extends keeps its abstract methods and the properties
+	 * MailPoet's patterns set.
+	 */
+	public function testPatternBaseClassStaysExtensible(): void {
+		$reflection = new \ReflectionClass( Abstract_Pattern::class );
+		$abstract   = array_map( fn( \ReflectionMethod $method ) => $method->getName(), $reflection->getMethods( \ReflectionMethod::IS_ABSTRACT ) );
+		sort( $abstract );
+
+		$this->assertSame( array( 'get_content', 'get_title' ), $abstract, 'A new abstract method would break MailPoet patterns.' );
+		foreach ( array( 'name', 'namespace', 'block_types', 'template_types', 'categories', 'post_types' ) as $property ) {
+			$this->assertTrue( $reflection->hasProperty( $property ), "Property \${$property} is missing." );
+			$this->assertFalse( $reflection->getProperty( $property )->isPrivate(), "Property \${$property} must stay overridable." );
+		}
+	}
+
+	/**
+	 * Test the constants MailPoet reads keep their values.
+	 *
+	 * @dataProvider constant_provider
+	 * @param string $constant Class constant.
+	 * @param string $expected Value MailPoet relies on.
+	 */
+	public function testConstantKeepsItsValue( string $constant, string $expected ): void {
+		$this->assertTrue( defined( $constant ), "{$constant} is missing." );
+		$this->assertSame( $expected, constant( $constant ) );
+	}
+
+	/**
+	 * Constants MailPoet reads.
+	 *
+	 * @return array<string, array{string, string}>
+	 */
+	public function constant_provider(): array {
+		return array(
+			'HTML rendering context' => array( Personalizer::class . '::RENDERING_CONTEXT_HTML', 'html' ),
+			'text rendering context' => array( Personalizer::class . '::RENDERING_CONTEXT_TEXT', 'text' ),
+			'link rendering context' => array( Personalizer::class . '::RENDERING_CONTEXT_HREF', 'href' ),
+			'rendering context key'  => array( Personalizer::class . '::RENDERING_CONTEXT_KEY', 'rendering_context' ),
+			'text value type'        => array( Personalization_Tag::class . '::VALUE_TYPE_TEXT', 'text' ),
+			'minimum WordPress'      => array( Dependency_Check::class . '::MIN_WP_VERSION', '6.7' ),
+		);
+	}
+
+	/**
+	 * Test the registries pass themselves to the filters MailPoet registers on.
+	 */
+	public function testRegistriesPassThemselvesToTheirFilters(): void {
+		$templates_registry = $this->di_container->get( Templates_Registry::class );
+		$tags_registry      = $this->di_container->get( Personalization_Tags_Registry::class );
+		$received           = array();
+		foreach ( array( 'woocommerce_email_editor_register_templates', 'woocommerce_email_editor_register_personalization_tags' ) as $hook ) {
+			add_filter(
+				$hook,
+				function ( $registry ) use ( &$received, $hook ) {
+					$received[ $hook ] = $registry;
+					return $registry;
+				}
+			);
+		}
+
+		$templates_registry->initialize();
+		$tags_registry->initialize();
+
+		$this->assertSame( $templates_registry, $received['woocommerce_email_editor_register_templates'] ?? null );
+		$this->assertSame( $tags_registry, $received['woocommerce_email_editor_register_personalization_tags'] ?? null );
+	}
+
+	/**
+	 * Test a preview email passes its personalization context through the filter MailPoet uses.
+	 */
+	public function testPreviewEmailFiltersItsPersonalizerContext(): void {
+		$seen = null;
+		add_filter(
+			'woocommerce_email_editor_send_preview_email_personalizer_context',
+			function ( $context ) use ( &$seen ) {
+				$seen = $context;
+				return array_merge( $context, array( 'mailpoet_value' => 'set by MailPoet' ) );
+			}
+		);
+		$this->di_container->get( Personalization_Tags_Registry::class )->register(
+			new Personalization_Tag( 'Value', 'mailpoet/value', 'Test', fn( $context ) => $context['mailpoet_value'] ?? '' )
+		);
+
+		$content = $this->di_container->get( Send_Preview_Email::class )->set_personalize_content( '<p><!--[mailpoet/value]--></p>' );
+
+		$this->assertIsArray( $seen );
+		$this->assertSame( '<p>set by MailPoet</p>', $content );
+	}
+
+	/**
+	 * Test initializing the editor fires the hooks MailPoet uses to set itself up.
+	 */
+	public function testInitializingFiresMailPoetSetupHooks(): void {
+		$initialized_count = did_action( 'woocommerce_email_editor_initialized' );
+		$filtered          = array();
+		foreach ( array( 'woocommerce_email_editor_post_types', 'woocommerce_is_email_editor_page' ) as $hook ) {
+			add_filter(
+				$hook,
+				function ( $value ) use ( &$filtered, $hook ) {
+					$filtered[ $hook ] = true;
+					return $value;
+				}
+			);
+		}
+
+		$this->di_container->get( Email_Editor::class )->initialize();
+
+		$this->assertSame( $initialized_count + 1, did_action( 'woocommerce_email_editor_initialized' ) );
+		$this->assertArrayHasKey( 'woocommerce_email_editor_post_types', $filtered );
+		$this->assertArrayHasKey( 'woocommerce_is_email_editor_page', $filtered );
+	}
+
+	/**
+	 * Test rendering an email fires the rendering hooks MailPoet uses, with the arguments it reads.
+	 */
+	public function testRenderingFiresMailPoetHooks(): void {
+		$this->di_container->get( Email_Editor::class )->initialize();
+		$post_id = $this->factory->post->create( array( 'post_content' => '<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->' ) );
+		$this->assertIsInt( $post_id );
+		$post = get_post( $post_id );
+		$this->assertInstanceOf( \WP_Post::class, $post );
+		$args  = array();
+		$hooks = array(
+			'woocommerce_email_editor_rendering_email_context' => 3,
+			'woocommerce_email_blocks_renderer_parsed_blocks' => 1,
+			'woocommerce_email_content_renderer_styles' => 2,
+		);
+		foreach ( $hooks as $hook => $accepted ) {
+			add_filter(
+				$hook,
+				function ( ...$values ) use ( &$args, $hook ) {
+					$args[ $hook ] = $values;
+					return $values[0];
+				},
+				10,
+				$accepted
+			);
+		}
+		$render_start_count = did_action( 'woocommerce_email_editor_render_start' );
+
+		$this->di_container->get( Renderer::class )->render( $post, 'Subject', '', 'en_US' );
+
+		$this->assertSame( $render_start_count + 1, did_action( 'woocommerce_email_editor_render_start' ) );
+		$this->assertIsArray( $args['woocommerce_email_editor_rendering_email_context'][0] ?? null, 'Context array' );
+		$this->assertSame( $post, $args['woocommerce_email_editor_rendering_email_context'][1] ?? null, 'Rendered post' );
+		$this->assertInstanceOf( \WP_Block_Template::class, $args['woocommerce_email_editor_rendering_email_context'][2] ?? null, 'Template' );
+		$this->assertIsArray( $args['woocommerce_email_blocks_renderer_parsed_blocks'][0] ?? null, 'Parsed blocks' );
+		$this->assertIsString( $args['woocommerce_email_content_renderer_styles'][0] ?? null, 'Styles' );
+		$this->assertSame( $post, $args['woocommerce_email_content_renderer_styles'][1] ?? null, 'Post for styles' );
+	}
+}
diff --git a/packages/php/email-editor/tests/integration/Validator/Validator_Test.php b/packages/php/email-editor/tests/integration/Validator/Validator_Test.php
new file mode 100644
index 00000000000..f65e68529db
--- /dev/null
+++ b/packages/php/email-editor/tests/integration/Validator/Validator_Test.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare(strict_types = 1);
+namespace Automattic\WooCommerce\EmailEditor\Validator;
+
+/**
+ * Integration test for the Validator, which accepts or rejects saved email data. It uses the
+ * WordPress REST schema functions, so it needs WordPress.
+ */
+class Validator_Test extends \Email_Editor_Integration_Test_Case {
+	/**
+	 * The System Under Test.
+	 *
+	 * @var Validator
+	 */
+	private Validator $sut;
+
+	/**
+	 * Set up before each test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new Validator();
+	}
+
+	/**
+	 * Test it accepts values of the exact type and returns them.
+	 *
+	 * @dataProvider valid_value_provider
+	 * @param Schema $schema   Schema.
+	 * @param mixed  $value    Value.
+	 * @param mixed  $expected Value the validator returns.
+	 */
+	public function testItAcceptsValidValues( Schema $schema, $value, $expected ): void {
+		$this->assertSame( $expected, $this->sut->validate( $schema, $value ) );
+	}
+
+	/**
+	 * Valid values.
+	 *
+	 * @return array<string, array{Schema, mixed, mixed}>
+	 */
+	public function valid_value_provider(): array {
+		return array(
+			'integer'                  => array( Builder::integer(), 5, 5 ),
+			'whole float as number'    => array( Builder::number(), 2.0, 2.0 ),
+			'boolean'                  => array( Builder::boolean(), false, false ),
+			'null for nullable'        => array( Builder::string()->nullable(), null, null ),
+			'object'                   => array( Builder::object( array( 'name' => Builder::string() ) ), array( 'name' => 'Ada' ), array( 'name' => 'Ada' ) ),
+			'stdClass object'          => array( Builder::object( array( 'name' => Builder::string() ) ), (object) array( 'name' => 'Ada' ), array( 'name' => 'Ada' ) ),
+			'first matching anyOf'     => array( Builder::any_of( array( Builder::integer(), Builder::string() ) ), 'text', 'text' ),
+			'single matching oneOf'    => array( Builder::one_of( array( Builder::integer(), Builder::string() ) ), 7, 7 ),
+			'additional property type' => array( Builder::object()->additionalProperties( Builder::integer() ), array( 'any' => 1 ), array( 'any' => 1 ) ),
+		);
+	}
+
+	/**
+	 * Test it rejects values WordPress would otherwise coerce or accept.
+	 *
+	 * @dataProvider invalid_value_provider
+	 * @param Schema $schema Schema.
+	 * @param mixed  $value  Value.
+	 * @param string $param  Parameter path the error message names.
+	 */
+	public function testItRejectsInvalidValues( Schema $schema, $value, string $param ): void {
+		try {
+			$this->sut->validate( $schema, $value );
+			$this->fail( 'Expected a validation error.' );
+		} catch ( Validation_Exception $e ) {
+			$this->assertStringContainsString( $param . ' ', $e->get_wp_error()->get_error_message(), 'The error names the parameter.' );
+		}
+	}
+
+	/**
+	 * Invalid values.
+	 *
+	 * @return array<string, array{Schema, mixed, string}>
+	 */
+	public function invalid_value_provider(): array {
+		$nested = Builder::object( array( 'a' => Builder::array( Builder::integer() ) ) );
+		return array(
+			'numeric string for integer'  => array( Builder::integer(), '5', 'value' ),
+			'float for integer'           => array( Builder::integer(), 5.5, 'value' ),
+			'numeric string for number'   => array( Builder::number(), '1.5', 'value' ),
+			'string for boolean'          => array( Builder::boolean(), 'true', 'value' ),
+			'null for non-nullable'       => array( Builder::string(), null, 'value' ),
+			'object with a numeric key'   => array(
+				Builder::object(),
+				array(
+					'a' => 1,
+					2,
+				),
+				'value',
+			),
+			'nested value names its path' => array( $nested, array( 'a' => array( 1, 'x' ) ), 'value[a][1]' ),
+			'additional property type'    => array( Builder::object()->additionalProperties( Builder::integer() ), array( 'any' => 'x' ), 'value[any]' ),
+			'no matching anyOf'           => array( Builder::any_of( array( Builder::integer(), Builder::boolean() ) ), 'text', 'value' ),
+			'two matching oneOf'          => array( Builder::one_of( array( Builder::number(), Builder::integer() ) ), 7, 'value' ),
+		);
+	}
+
+	/**
+	 * Test the builder produces the REST schema array.
+	 */
+	public function testBuilderProducesSchemaArray(): void {
+		$schema = Builder::object( array( 'subject' => Builder::string()->nullable() ) )->required()->to_array();
+
+		$this->assertSame( 'object', $schema['type'] );
+		$this->assertSame( array( 'string', 'null' ), $schema['properties']['subject']['type'] );
+		$this->assertTrue( $schema['required'] );
+	}
+}
diff --git a/plugins/woocommerce/changelog/email-editor-php-test-coverage b/plugins/woocommerce/changelog/email-editor-php-test-coverage
new file mode 100644
index 00000000000..d0e14ff2230
--- /dev/null
+++ b/plugins/woocommerce/changelog/email-editor-php-test-coverage
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Add tests for the email editor product collection, renderers and integration; no production change.
+
diff --git a/plugins/woocommerce/changelog/fix-email-site-title-apostrophe b/plugins/woocommerce/changelog/fix-email-site-title-apostrophe
new file mode 100644
index 00000000000..a823e71d29f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-email-site-title-apostrophe
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show apostrophes in the site title personalization tag of block emails on PHP 8.0 and older, instead of an HTML code.
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/SiteTagsProvider.php b/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/SiteTagsProvider.php
index 506908ac520..6902372d1e9 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/SiteTagsProvider.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/SiteTagsProvider.php
@@ -33,7 +33,7 @@ class SiteTagsProvider extends AbstractTagProvider {
 						$store_name = get_option( 'woocommerce_pos_store_name' );
 						return htmlspecialchars_decode( empty( $store_name ) ? PointOfSaleDefaultSettings::get_default_store_name() : $store_name, ENT_QUOTES );
 					}
-					return htmlspecialchars_decode( get_bloginfo( 'name' ) );
+					return wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
 				},
 				array(),
 				null,
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/BlockEmailRendererTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/BlockEmailRendererTest.php
index f54f724b5c1..f48a361099a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/BlockEmailRendererTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/BlockEmailRendererTest.php
@@ -532,6 +532,68 @@ class BlockEmailRendererTest extends \WC_Unit_Test_Case {
 		return '';
 	}

+	/**
+	 * @testdox Gives blocks the first valid recipient and the order's customer while rendering.
+	 */
+	public function testItPassesRecipientAndOrderCustomerToBlocks(): void {
+		$this->skip_if_unsupported_environment();
+		$customer_id          = $this->factory()->user->create();
+		$order                = wc_create_order( array( 'customer_id' => $customer_id ) );
+		$wc_mail_mock         = $this->create_test_email_mock( 'not-an-email, buyer@test.com, other@test.com' );
+		$wc_mail_mock->object = $order;
+		$seen_context         = null;
+		add_filter(
+			'woocommerce_email_editor_rendering_email_context',
+			function ( $context ) use ( &$seen_context ) {
+				$seen_context = $context;
+				return $context;
+			},
+			20
+		);
+
+		$this->block_email_renderer->maybe_render_block_email( $wc_mail_mock );
+
+		$this->assertSame( 'buyer@test.com', $seen_context['recipient_email'] ?? null );
+		$this->assertSame( $customer_id, $seen_context['user_id'] ?? null );
+	}
+
+	/**
+	 * @testdox Falls back to the classic email and removes its context filter when rendering fails.
+	 */
+	public function testItFallsBackAndCleansUpWhenRenderingFails(): void {
+		$this->skip_if_unsupported_environment();
+		$thrown = false;
+		$throw  = function () use ( &$thrown ) {
+			$thrown = true;
+			throw new \RuntimeException( 'Render failed.' );
+		};
+		add_filter( 'woocommerce_email_editor_rendering_email_context', $throw, 20 );
+		$filters_before = count( $GLOBALS['wp_filter']['woocommerce_email_editor_rendering_email_context']->callbacks[10] ?? array() );
+
+		$rendered_email = $this->block_email_renderer->maybe_render_block_email( $this->create_test_email_mock( 'customer@test.com' ) );
+
+		$this->assertTrue( $thrown, 'The render should reach the failing filter.' );
+		$this->assertNull( $rendered_email );
+		$this->assertCount( $filters_before, $GLOBALS['wp_filter']['woocommerce_email_editor_rendering_email_context']->callbacks[10] ?? array() );
+	}
+
+	/**
+	 * Mock of the test email with the given recipient.
+	 *
+	 * @param string $recipient Recipient list.
+	 * @return \WC_Email&\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private function create_test_email_mock( string $recipient ) {
+		$wc_mail_mock     = $this->createMock( \WC_Email::class );
+		$wc_mail_mock->id = 'test_email';
+		$wc_mail_mock->method( 'get_recipient' )->willReturn( $recipient );
+		$wc_mail_mock->method( 'get_subject' )->willReturn( 'Test Woo Email' );
+		$wc_mail_mock->method( 'get_preheader' )->willReturn( 'Test Woo Preheader' );
+		$wc_mail_mock->method( 'get_content_html' )->willReturn( 'Woo content' );
+		$wc_mail_mock->method( 'get_block_editor_email_template_content' )->willReturn( 'Woo content' );
+		return $wc_mail_mock;
+	}
+
 	/**
 	 * Skip test if the environment doesn't fulfill minimal requirements.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailTemplates/TemplatesControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailTemplates/TemplatesControllerTest.php
new file mode 100644
index 00000000000..6254872d395
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailTemplates/TemplatesControllerTest.php
@@ -0,0 +1,75 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\EmailTemplates;
+
+use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplatesController;
+use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\WooEmailTemplate;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the TemplatesController class: the email template stays out of the Site Editor.
+ */
+class TemplatesControllerTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var TemplatesController
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new TemplatesController();
+	}
+
+	/**
+	 * Clean up the admin screen.
+	 */
+	public function tearDown(): void {
+		set_current_screen( 'front' );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Leaves the WooCommerce email template out of the Site Editor and keeps site templates.
+	 */
+	public function test_site_editor_does_not_list_the_email_template(): void {
+		set_current_screen( 'site-editor' );
+
+		$slugs = $this->filtered_slugs();
+
+		$this->assertSame( array( 'single' ), $slugs );
+	}
+
+	/**
+	 * @testdox Keeps the email template on other admin screens and outside the admin.
+	 * @testWith ["edit-post"]
+	 *           ["front"]
+	 *
+	 * @param string $screen Current screen.
+	 */
+	public function test_other_screens_keep_the_email_template( string $screen ): void {
+		set_current_screen( $screen );
+
+		$this->assertSame( array( 'single', WooEmailTemplate::TEMPLATE_SLUG ), $this->filtered_slugs() );
+	}
+
+	/**
+	 * Slugs left after filtering a site template and the email template.
+	 */
+	private function filtered_slugs(): array {
+		$site_template        = new \WP_Block_Template();
+		$site_template->slug  = 'single';
+		$email_template       = new \WP_Block_Template();
+		$email_template->slug = WooEmailTemplate::TEMPLATE_SLUG;
+
+		$templates = $this->sut->filter_email_templates( array( $site_template, $email_template ) );
+
+		return array_values( array_map( fn( $template ) => $template->slug, $templates ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PackageTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PackageTest.php
new file mode 100644
index 00000000000..be387a0563d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PackageTest.php
@@ -0,0 +1,30 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor;
+
+use Automattic\WooCommerce\Internal\EmailEditor\Package;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Package class: the block email editor starts only when its feature is on.
+ */
+class PackageTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Starts the email editor only when the block email editor feature is on.
+	 * @testWith ["no", false]
+	 *           ["yes", true]
+	 *
+	 * @param string $option  Feature option value.
+	 * @param bool   $started Whether the email editor starts.
+	 */
+	public function test_starts_only_when_feature_is_on( string $option, bool $started ): void {
+		update_option( 'woocommerce_feature_block_email_editor_enabled', $option );
+		remove_all_filters( 'woocommerce_email_editor_initialized' );
+
+		Package::init();
+
+		$this->assertSame( $started, has_filter( 'woocommerce_email_editor_initialized' ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/CustomerTagsProviderTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/CustomerTagsProviderTest.php
new file mode 100644
index 00000000000..42f580db6e6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/CustomerTagsProviderTest.php
@@ -0,0 +1,109 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\PersonalizationTags;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger;
+use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
+use Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags\CustomerTagsProvider;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the CustomerTagsProvider class: which customer values an email shows.
+ */
+class CustomerTagsProviderTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Registry with the customer tags registered.
+	 *
+	 * @var Personalization_Tags_Registry
+	 */
+	private $registry;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->registry = new Personalization_Tags_Registry( new Email_Editor_Logger() );
+		( new CustomerTagsProvider() )->register_tags( $this->registry );
+	}
+
+	/**
+	 * @testdox Takes customer values from the order's billing details.
+	 * @testWith ["customer-email", "billing@example.com"]
+	 *           ["customer-first-name", "Ada"]
+	 *           ["customer-last-name", "Lovelace"]
+	 *           ["customer-full-name", "Ada Lovelace"]
+	 *           ["customer-country", "Germany"]
+	 *
+	 * @param string $tag      Tag name without the woocommerce/ prefix.
+	 * @param string $expected Expected value.
+	 */
+	public function test_values_from_order( string $tag, string $expected ): void {
+		$order = wc_create_order();
+		$order->set_billing_email( 'billing@example.com' );
+		$order->set_billing_first_name( 'Ada' );
+		$order->set_billing_last_name( 'Lovelace' );
+		$order->set_billing_country( 'DE' );
+
+		$this->assertSame( $expected, $this->value( $tag, array( 'order' => $order ) ) );
+	}
+
+	/**
+	 * @testdox Takes customer values from the WordPress user when there is no order.
+	 * @testWith ["customer-first-name", "Grace"]
+	 *           ["customer-last-name", "Hopper"]
+	 *           ["customer-full-name", "Grace Hopper"]
+	 *           ["customer-username", "grace"]
+	 *
+	 * @param string $tag      Tag name without the woocommerce/ prefix.
+	 * @param string $expected Expected value.
+	 */
+	public function test_values_from_user( string $tag, string $expected ): void {
+		$user = $this->factory->user->create_and_get(
+			array(
+				'user_login' => 'grace',
+				'first_name' => 'Grace',
+				'last_name'  => 'Hopper',
+			)
+		);
+
+		$this->assertSame( $expected, $this->value( $tag, array( 'wp_user' => $user ) ) );
+	}
+
+	/**
+	 * @testdox Uses the recipient for the email and leaves other values empty without an order or user.
+	 * @testWith ["customer-email", "recipient@example.com"]
+	 *           ["customer-first-name", ""]
+	 *           ["customer-full-name", ""]
+	 *           ["customer-username", ""]
+	 *           ["customer-country", ""]
+	 *
+	 * @param string $tag      Tag name without the woocommerce/ prefix.
+	 * @param string $expected Expected value.
+	 */
+	public function test_values_without_order_or_user( string $tag, string $expected ): void {
+		$this->assertSame( $expected, $this->value( $tag, array( 'recipient_email' => 'recipient@example.com' ) ) );
+	}
+
+	/**
+	 * @testdox Shows the country code when WooCommerce doesn't know the country.
+	 */
+	public function test_unknown_country_shows_its_code(): void {
+		$order = wc_create_order();
+		$order->set_billing_country( 'XX' );
+
+		$this->assertSame( 'XX', $this->value( 'customer-country', array( 'order' => $order ) ) );
+	}
+
+	/**
+	 * Resolves a tag in the given context.
+	 *
+	 * @param string $tag     Tag name without the woocommerce/ prefix.
+	 * @param array  $context Personalization context.
+	 */
+	private function value( string $tag, array $context ): string {
+		return $this->registry->get_by_token( "[woocommerce/{$tag}]" )->execute_callback( $context );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/SiteTagsProviderTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/SiteTagsProviderTest.php
new file mode 100644
index 00000000000..4bfeda6b326
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/SiteTagsProviderTest.php
@@ -0,0 +1,60 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\PersonalizationTags;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger;
+use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
+use Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags\SiteTagsProvider;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the SiteTagsProvider class: the site title an email shows.
+ */
+class SiteTagsProviderTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Registry with the site tags registered.
+	 *
+	 * @var Personalization_Tags_Registry
+	 */
+	private $registry;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->registry = new Personalization_Tags_Registry( new Email_Editor_Logger() );
+		( new SiteTagsProvider() )->register_tags( $this->registry );
+		update_option( 'blogname', "Pavel's \"Best\" Shop" );
+	}
+
+	/**
+	 * @testdox Shows the site name with quotes decoded.
+	 */
+	public function test_site_title_decodes_quotes(): void {
+		$this->assertSame( "Pavel's \"Best\" Shop", $this->value( array( 'order' => wc_create_order() ) ) );
+		$this->assertSame( "Pavel's \"Best\" Shop", $this->value( array() ), 'Without an order' );
+	}
+
+	/**
+	 * @testdox Shows the point of sale store name for an order placed at the point of sale.
+	 */
+	public function test_site_title_for_pos_order(): void {
+		update_option( 'woocommerce_pos_store_name', 'Market Stall' );
+		$order = wc_create_order();
+		$order->set_created_via( 'pos-rest-api' );
+
+		$this->assertSame( 'Market Stall', $this->value( array( 'order' => $order ) ) );
+	}
+
+	/**
+	 * Resolves the site title tag in the given context.
+	 *
+	 * @param array $context Personalization context.
+	 */
+	private function value( array $context ): string {
+		return $this->registry->get_by_token( '[woocommerce/site-title]' )->execute_callback( $context );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/StoreTagsProviderTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/StoreTagsProviderTest.php
new file mode 100644
index 00000000000..0303b2043d8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/PersonalizationTags/StoreTagsProviderTest.php
@@ -0,0 +1,90 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\PersonalizationTags;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger;
+use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
+use Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags\StoreTagsProvider;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the StoreTagsProvider class: which store values an email shows.
+ */
+class StoreTagsProviderTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Registry with the store tags registered.
+	 *
+	 * @var Personalization_Tags_Registry
+	 */
+	private $registry;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		if ( ! class_exists( \WC_Email::class ) ) {
+			require_once WC_ABSPATH . 'includes/emails/class-wc-email.php';
+		}
+		$this->registry = new Personalization_Tags_Registry( new Email_Editor_Logger() );
+		( new StoreTagsProvider() )->register_tags( $this->registry );
+	}
+
+	/**
+	 * @testdox Uses the email's sender name and address when the email sets them.
+	 */
+	public function test_store_name_and_email_from_wc_email(): void {
+		$wc_email = $this->create_wc_email( 'Shop Team', 'team@example.com' );
+
+		$this->assertSame( 'Shop Team', $this->value( 'store-name', array( 'wc_email' => $wc_email ) ) );
+		$this->assertSame( 'team@example.com', $this->value( 'store-email', array( 'wc_email' => $wc_email ) ) );
+	}
+
+	/**
+	 * @testdox Falls back to the site name, with quotes decoded, and the admin email.
+	 */
+	public function test_store_name_and_email_fallbacks(): void {
+		update_option( 'blogname', "Pavel's \"Best\" Shop" );
+		update_option( 'admin_email', 'admin@example.com' );
+
+		$this->assertSame( "Pavel's \"Best\" Shop", $this->value( 'store-name', array( 'wc_email' => $this->create_wc_email( '', 'team@example.com' ) ) ) );
+		$this->assertSame( 'admin@example.com', $this->value( 'store-email', array() ) );
+	}
+
+	/**
+	 * @testdox Shows the customer note with its line breaks, and nothing without a note.
+	 */
+	public function test_admin_order_note(): void {
+		$wc_email                = $this->create_wc_email( '', '' );
+		$wc_email->customer_note = "First line\nSecond line";
+
+		$this->assertSame( "First line<br />\nSecond line", $this->value( 'admin-order-note', array( 'wc_email' => $wc_email ) ) );
+		$this->assertSame( '', $this->value( 'admin-order-note', array( 'wc_email' => $this->create_wc_email( '', '' ) ) ) );
+	}
+
+	/**
+	 * Resolves a tag in the given context.
+	 *
+	 * @param string $tag     Tag name without the woocommerce/ prefix.
+	 * @param array  $context Personalization context.
+	 */
+	private function value( string $tag, array $context ): string {
+		return $this->registry->get_by_token( "[woocommerce/{$tag}]" )->execute_callback( $context );
+	}
+
+	/**
+	 * Email with the given sender.
+	 *
+	 * @param string $from_name    Sender name.
+	 * @param string $from_address Sender address.
+	 * @return \WC_Email&\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private function create_wc_email( string $from_name, string $from_address ) {
+		$wc_email = $this->createMock( \WC_Email::class );
+		$wc_email->method( 'get_from_name' )->willReturn( $from_name );
+		$wc_email->method( 'get_from_address' )->willReturn( $from_address );
+		return $wc_email;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/ProductCollectionTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/ProductCollectionTest.php
new file mode 100644
index 00000000000..41fa651d009
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/Renderer/Blocks/ProductCollectionTest.php
@@ -0,0 +1,370 @@
+<?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_Collection;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Product_Collection email block renderer: which products an email shows, and how.
+ */
+class ProductCollectionTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var Product_Collection
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new Product_Collection();
+	}
+
+	/**
+	 * @testdox Shows hand-picked products in the order they were picked, without excluded ones.
+	 */
+	public function test_hand_picked_products_keep_their_order(): void {
+		$first  = $this->create_product( 'Picked first' );
+		$second = $this->create_product( 'Picked second' );
+		$third  = $this->create_product( 'Picked excluded' );
+
+		$names = $this->rendered_names(
+			array(
+				'woocommerceHandPickedProducts' => array( $second->get_id(), $first->get_id(), $third->get_id() ),
+				'exclude'                       => array( $third->get_id() ),
+			)
+		);
+
+		$this->assertSame( array( 'Picked second', 'Picked first' ), $names );
+	}
+
+	/**
+	 * @testdox Shows only featured products in the featured collection.
+	 */
+	public function test_featured_collection_shows_featured_products(): void {
+		$this->create_product( 'Featured', array( 'featured' => true ) );
+		$this->create_product( 'Plain' );
+
+		$this->assertSame( array( 'Featured' ), $this->rendered_names( array(), 'woocommerce/product-collection/featured' ) );
+	}
+
+	/**
+	 * @testdox Shows products on sale in the on-sale collection, including variable products, but not ended sales.
+	 */
+	public function test_on_sale_collection_follows_current_sales(): void {
+		$this->create_product( 'Simple on sale', array( 'sale_price' => 5 ) );
+		$this->create_product( 'Full price' );
+		$this->create_product(
+			'Sale ended',
+			array(
+				'sale_price'        => 5,
+				'date_on_sale_from' => '-10 days',
+				'date_on_sale_to'   => '-2 days',
+			)
+		);
+		$variable = WC_Helper_Product::create_variation_product();
+		$variable->set_name( 'Variable on sale' );
+		$variable->save();
+		$variation = wc_get_product( $variable->get_children()[0] );
+		$variation->set_sale_price( '1' );
+		$variation->save();
+		\WC_Product_Variable::sync( $variable->get_id() );
+		delete_transient( 'wc_products_onsale' );
+
+		$names = $this->rendered_names( array(), 'woocommerce/product-collection/on-sale' );
+		sort( $names );
+
+		$this->assertSame( array( 'Simple on sale', 'Variable on sale' ), $names );
+	}
+
+	/**
+	 * @testdox Leaves out products hidden from the shop catalog.
+	 */
+	public function test_hides_products_hidden_from_catalog(): void {
+		$this->create_product( 'Visible' );
+		$hidden = $this->create_product( 'Hidden', array( 'catalog_visibility' => 'hidden' ) );
+
+		$this->assertSame( array( 'Visible' ), $this->rendered_names( array() ) );
+		$this->assertSame( array(), $this->rendered_names( array( 'woocommerceHandPickedProducts' => array( $hidden->get_id() ) ) ), 'Hand-picked' );
+	}
+
+	/**
+	 * @testdox Filters by stock status only when not every status is selected.
+	 */
+	public function test_stock_status_filter(): void {
+		$this->create_product( 'In stock' );
+		$this->create_product( 'Sold out', array( 'stock_status' => 'outofstock' ) );
+
+		$this->assertSame( array( 'In stock' ), $this->rendered_names( array( 'woocommerceStockStatus' => array( 'instock' ) ) ), 'Only in stock' );
+
+		$all   = array_keys( wc_get_product_stock_status_options() );
+		$names = $this->rendered_names( array( 'woocommerceStockStatus' => $all ) );
+		sort( $names );
+		$this->assertSame( array( 'In stock', 'Sold out' ), $names, 'All statuses' );
+	}
+
+	/**
+	 * @testdox Shows the upsells and cross-sells of the referenced product, without the product itself.
+	 */
+	public function test_upsells_and_cross_sells_of_referenced_product(): void {
+		$upsell     = $this->create_product( 'Upsell' );
+		$cross_sell = $this->create_product( 'Cross-sell' );
+		$main       = $this->create_product(
+			'Main',
+			array(
+				'upsell_ids'     => array( $upsell->get_id() ),
+				'cross_sell_ids' => array( $cross_sell->get_id() ),
+			)
+		);
+		$query      = array( 'productReference' => $main->get_id() );
+
+		$this->assertSame( array( 'Upsell' ), $this->rendered_names( $query, 'woocommerce/product-collection/upsells' ) );
+		$this->assertSame( array( 'Cross-sell' ), $this->rendered_names( $query, 'woocommerce/product-collection/cross-sells' ) );
+	}
+
+	/**
+	 * @testdox Shows no products in a related collection without a product reference.
+	 * @testWith ["woocommerce/product-collection/upsells"]
+	 *           ["woocommerce/product-collection/cross-sells"]
+	 *           ["woocommerce/product-collection/related"]
+	 *
+	 * @param string $collection Collection type.
+	 */
+	public function test_related_collections_without_reference_show_nothing( string $collection ): void {
+		$this->create_product( 'Any product' );
+
+		$html = $this->render( array(), $collection );
+
+		$this->assertSame( array(), $this->names_in( $html ) );
+		$this->assertStringContainsString( 'No products found.', $html );
+	}
+
+	/**
+	 * @testdox Shows the products in the recipient's saved cart.
+	 */
+	public function test_cart_contents_for_customer_with_cart(): void {
+		$in_cart = $this->create_product( 'In cart' );
+		$this->create_product( 'Not in cart' );
+		$user_id = $this->factory->user->create( array( 'user_email' => 'shopper@example.com' ) );
+		update_user_meta( $user_id, '_woocommerce_persistent_cart_' . get_current_blog_id(), array( 'cart' => array( 'key' => array( 'product_id' => $in_cart->get_id() ) ) ) );
+
+		$names = $this->rendered_names( array(), 'woocommerce/product-collection/cart-contents', array( 'recipient_email' => 'shopper@example.com' ) );
+
+		$this->assertSame( array( 'In cart' ), $names );
+	}
+
+	/**
+	 * @testdox Shows no products for a recipient without a cart, and sample products in a preview.
+	 */
+	public function test_cart_contents_without_cart(): void {
+		$this->create_product( 'Sample' );
+
+		$this->assertSame( array(), $this->rendered_names( array(), 'woocommerce/product-collection/cart-contents', array( 'recipient_email' => 'guest@example.com' ) ), 'Sent email' );
+		$this->assertSame( array( 'Sample' ), $this->rendered_names( array(), 'woocommerce/product-collection/cart-contents', array( 'is_user_preview' => true ) ), 'Preview' );
+	}
+
+	/**
+	 * @testdox Keeps the on-sale filter on an upsells collection.
+	 */
+	public function test_on_sale_filter_applies_to_upsells(): void {
+		$sale = $this->create_product( 'Upsell on sale', array( 'sale_price' => 5 ) );
+		$full = $this->create_product( 'Upsell full price' );
+		$main = $this->create_product( 'Main', array( 'upsell_ids' => array( $sale->get_id(), $full->get_id() ) ) );
+		delete_transient( 'wc_products_onsale' );
+
+		$names = $this->rendered_names(
+			array(
+				'productReference'  => $main->get_id(),
+				'woocommerceOnSale' => true,
+			),
+			'woocommerce/product-collection/upsells'
+		);
+
+		$this->assertSame( array( 'Upsell on sale' ), $names );
+	}
+
+	/**
+	 * @testdox Shows every product in the recipient's cart, also ones hidden from the catalog, as the shop's cart does.
+	 */
+	public function test_cart_contents_include_catalog_hidden_products(): void {
+		$visible = $this->create_product( 'Visible in cart' );
+		$hidden  = $this->create_product( 'Search-only in cart', array( 'catalog_visibility' => 'search' ) );
+		$user_id = $this->factory->user->create( array( 'user_email' => 'shopper@example.com' ) );
+		update_user_meta(
+			$user_id,
+			'_woocommerce_persistent_cart_' . get_current_blog_id(),
+			array(
+				'cart' => array(
+					'a' => array( 'product_id' => $visible->get_id() ),
+					'b' => array( 'product_id' => $hidden->get_id() ),
+				),
+			)
+		);
+
+		$names = $this->rendered_names( array(), 'woocommerce/product-collection/cart-contents', array( 'recipient_email' => 'shopper@example.com' ) );
+		sort( $names );
+
+		$this->assertSame( array( 'Search-only in cart', 'Visible in cart' ), $names );
+	}
+
+	/**
+	 * @testdox Filters by category, in both forms the block stores, and by attribute.
+	 */
+	public function test_category_and_attribute_filters(): void {
+		$category = wp_insert_term( 'Shirts', 'product_cat' );
+		$shirt    = $this->create_product( 'Shirt', array( 'category_ids' => array( $category['term_id'] ) ) );
+		$this->create_product( 'Mug' );
+		$attribute = WC_Helper_Product::create_attribute( 'colour', array( 'red', 'blue' ) );
+		wp_set_object_terms( $shirt->get_id(), array( 'red' ), $attribute['attribute_taxonomy'] );
+
+		$this->assertSame( array( 'Shirt' ), $this->rendered_names( array( 'taxQuery' => array( 'product_cat' => array( $category['term_id'] ) ) ) ), 'Taxonomy map' );
+		$this->assertSame(
+			array( 'Shirt' ),
+			$this->rendered_names(
+				array(
+					'taxQuery' => array(
+						array(
+							'taxonomy' => 'product_cat',
+							'field'    => 'term_id',
+							'terms'    => array( $category['term_id'] ),
+						),
+					),
+				)
+			),
+			'Clause list'
+		);
+		$this->assertSame(
+			array( 'Shirt' ),
+			$this->rendered_names(
+				array(
+					'woocommerceAttributes' => array(
+						array(
+							'taxonomy' => $attribute['attribute_taxonomy'],
+							'termId'   => $attribute['term_ids'][0],
+						),
+					),
+				)
+			),
+			'Attribute'
+		);
+	}
+
+	/**
+	 * @testdox Skips the number of products set as the offset.
+	 */
+	public function test_offset_skips_products(): void {
+		$ids = array();
+		foreach ( array( 'First', 'Second', 'Third' ) as $name ) {
+			$ids[] = $this->create_product( $name )->get_id();
+		}
+
+		$names = $this->rendered_names(
+			array(
+				'woocommerceHandPickedProducts' => $ids,
+				'offset'                        => 1,
+			)
+		);
+
+		$this->assertSame( array( 'Second', 'Third' ), $names );
+	}
+
+	/**
+	 * @testdox Fills the last row with an empty cell in a two-column grid with an odd product count.
+	 */
+	public function test_two_columns_with_odd_product_count(): void {
+		$ids = array();
+		foreach ( array( 'One', 'Two', 'Three' ) as $name ) {
+			$ids[] = $this->create_product( $name )->get_id();
+		}
+
+		$html = $this->render( array( 'woocommerceHandPickedProducts' => $ids ), '', array(), 2 );
+
+		$this->assertSame( array( 'One', 'Two', 'Three' ), $this->names_in( $html ) );
+		$this->assertSame( 2, substr_count( $html, '<tr><td style="width: 50%' ), 'Two product rows' );
+		$this->assertStringContainsString( 'padding-left: 10px;"></td></tr>', $html, 'Empty cell after the third product' );
+	}
+
+	/**
+	 * Renders a collection and returns the product names it shows, in order.
+	 *
+	 * @param array  $query         Collection query attributes.
+	 * @param string $collection    Collection type.
+	 * @param array  $email_context Email context.
+	 */
+	private function rendered_names( array $query, string $collection = '', array $email_context = array() ): array {
+		return $this->names_in( $this->render( $query, $collection, $email_context ) );
+	}
+
+	/**
+	 * Renders a collection whose product template shows each product's title.
+	 *
+	 * @param array  $query         Collection query attributes.
+	 * @param string $collection    Collection type.
+	 * @param array  $email_context Email context.
+	 * @param int    $columns       Number of columns.
+	 */
+	private function render( array $query, string $collection = '', array $email_context = array(), int $columns = 1 ): string {
+		$title        = array(
+			'blockName'    => 'core/post-title',
+			'attrs'        => array(),
+			'innerBlocks'  => array(),
+			'innerHTML'    => '',
+			'innerContent' => array(),
+		);
+		$parsed_block = array(
+			'blockName'   => 'woocommerce/product-collection',
+			'attrs'       => array(
+				'collection'    => $collection,
+				'query'         => $query,
+				'displayLayout' => array( 'columns' => $columns ),
+			),
+			'innerBlocks' => array(
+				array(
+					'blockName'   => 'woocommerce/product-template',
+					'attrs'       => array(),
+					'innerBlocks' => array( $title ),
+				),
+			),
+		);
+		return $this->sut->render( '', $parsed_block, new Rendering_Context( new \WP_Theme_JSON(), $email_context ) );
+	}
+
+	/**
+	 * Product names created by this test that appear in the HTML, in order.
+	 *
+	 * @param string $html Rendered HTML.
+	 */
+	private function names_in( string $html ): array {
+		$document        = new \DOMDocument();
+		$previous_errors = libxml_use_internal_errors( true );
+		try {
+			$document->loadHTML( '<?xml encoding="utf-8"?>' . $html );
+		} finally {
+			libxml_clear_errors();
+			libxml_use_internal_errors( $previous_errors );
+		}
+		$names = array();
+		foreach ( ( new \DOMXPath( $document ) )->query( '//h1|//h2|//h3|//h4|//h5|//h6' ) as $heading ) {
+			$names[] = trim( html_entity_decode( wp_strip_all_tags( (string) $document->saveHTML( $heading ) ) ) );
+		}
+		return $names;
+	}
+
+	/**
+	 * Creates a published simple product.
+	 *
+	 * @param string $name  Product name.
+	 * @param array  $props Extra product properties.
+	 */
+	private function create_product( string $name, array $props = array() ): \WC_Product {
+		return WC_Helper_Product::create_simple_product( true, array_merge( array( 'name' => $name ), $props ) );
+	}
+}