Commit 2fa660b3b99 for woocommerce
commit 2fa660b3b99e12f33ae99abe9ba52439d445cc9a
Author: Patrick Zielinski <patrick.zielinski@a8c.com>
Date: Mon Apr 27 08:47:02 2026 -0400
[Email Editor] Add server-side reset endpoint that stamps sync meta (#64355)
* Stamp sync meta on woo_email post when REST update matches core render
* Add server-side reset endpoint for woo_email posts
Adds POST /woocommerce-email-editor/v1/emails/{id}/reset that atomically
rewrites the post_content to the canonical core template render and stamps
sync meta (_wc_email_template_version, _wc_email_template_source_hash,
_wc_email_last_synced_at, _wc_email_template_status = in_sync).
Renders content via WCTransactionalEmailPostsGenerator::compute_canonical_post_content()
so the byte sequence is identical to a fresh recreate, eliminating the hash
mismatch that drove the pivot away from client-side reset detection.
Gated on WCEmailTemplateSyncRegistry: emails not opted in are rejected with
a 403 rather than silently no-op'd. The post update + 4 meta writes are
wrapped in a single SQL transaction so a partial failure rolls back cleanly.
Initialise the previously-typed-but-uninitialised $post_manager property to
null so the existing 500 guard in get_default_content_response actually
returns the WP_Error instead of throwing on PHP 8.1+.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Switch reset action to call server-side reset endpoint
The Reset action in the email editor now POSTs to
/woocommerce-email-editor/v1/emails/{id}/reset, which atomically rewrites
post_content and stamps sync meta server-side. The client still updates the
in-memory editor state with the returned content so the user sees the reset
content without a page reload.
Replaces the previous GET /default-content + saveEditedEntityRecord round-trip,
which produced post_content that the divergence detector could not reliably
match against the core render due to JS/PHP serialization differences.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Remove Option A REST-update stamper
Drops WCEmailTemplateSyncRestStamper and its hook on rest_after_insert_woo_email,
along with the corresponding test. Reset detection no longer happens via hash
comparison on standard REST writes — the dedicated /reset endpoint introduced
in the previous commit handles it server-authoritatively.
Per Slack discussion (RSM-148 comment 2026-04-25), client-side reset producing
content that the server then hashes was unreliable due to JS/PHP serialization
differences. The server-side endpoint sidesteps the comparison entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: PZ01 <patrick.zielinski@automattic.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/rsm-148-stamp-sync-meta-on-email-post-reset b/plugins/woocommerce/changelog/rsm-148-stamp-sync-meta-on-email-post-reset
new file mode 100644
index 00000000000..a333bef552e
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-148-stamp-sync-meta-on-email-post-reset
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add server-side POST /woocommerce-email-editor/v1/emails/{id}/reset endpoint that atomically rewrites a woo_email post to its current core template render and stamps sync meta (version, source hash, synced-at, status=in_sync).
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx
index 74deef4a27d..2a4682f46dd 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx
@@ -104,9 +104,16 @@ const getResetNotificationEmailContentAction = () => {
try {
const response = ( await apiFetch( {
- path: `/woocommerce-email-editor/v1/emails/${ item.id }/default-content`,
+ path: `/woocommerce-email-editor/v1/emails/${ item.id }/reset`,
+ method: 'POST',
} ) ) as { content: string };
+ // Server has already persisted post_content + sync meta.
+ // Sync the editor's in-memory state so the user sees the
+ // reset content without a page reload. The trailing
+ // saveEditedEntityRecord is a content no-op (matches what
+ // the server just wrote) but keeps core-data's dirty
+ // tracking in a consistent state.
const blocks = parse(
response.content || ''
);
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 1eb3e83066a..884312d580a 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -64782,12 +64782,6 @@ parameters:
count: 2
path: src/Internal/EmailEditor/EmailApiController.php
- -
- message: '#^Property Automattic\\WooCommerce\\Internal\\EmailEditor\\EmailApiController\:\:\$post_manager \(Automattic\\WooCommerce\\Internal\\EmailEditor\\WCTransactionalEmails\\WCTransactionalEmailPostsManager\|null\) is never assigned null so it can be removed from the property type\.$#'
- identifier: property.unusedType
- count: 1
- path: src/Internal/EmailEditor/EmailApiController.php
-
-
message: '#^PHPDoc type array of property Automattic\\WooCommerce\\Internal\\EmailEditor\\EmailPatterns\\WooEmailContentPattern\:\:\$template_types is not covariant with PHPDoc type array\<string\> of overridden property Automattic\\WooCommerce\\EmailEditor\\Engine\\Patterns\\Abstract_Pattern\:\:\$template_types\.$#'
identifier: property.phpDocType
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
index 3e0e71957fa..0c16132de39 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
@@ -5,6 +5,8 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\EmailEditor;
use Automattic\WooCommerce\EmailEditor\Validator\Builder;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
use WC_Email;
@@ -26,7 +28,7 @@ class EmailApiController {
*
* @var WCTransactionalEmailPostsManager|null
*/
- private ?WCTransactionalEmailPostsManager $post_manager;
+ private ?WCTransactionalEmailPostsManager $post_manager = null;
/**
* The WooCommerce transactional email posts generator.
@@ -283,6 +285,27 @@ class EmailApiController {
'schema' => array( $this, 'get_default_content_schema' ),
)
);
+
+ register_rest_route(
+ 'woocommerce-email-editor/v1',
+ '/emails/(?P<id>\d+)/reset',
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'reset_response' ),
+ 'permission_callback' => function () {
+ return current_user_can( 'manage_woocommerce' );
+ },
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'The ID of the woo_email post.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'required' => true,
+ 'sanitize_callback' => 'absint',
+ ),
+ ),
+ 'schema' => array( $this, 'get_reset_schema' ),
+ )
+ );
}
/**
@@ -338,4 +361,133 @@ class EmailApiController {
200
);
}
+
+ /**
+ * Get the schema for the reset endpoint response.
+ *
+ * @return array
+ */
+ public function get_reset_schema(): array {
+ return array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'woo_email_reset',
+ 'type' => 'object',
+ 'properties' => array(
+ 'content' => array(
+ 'description' => __( 'The canonical block content written to the post.', 'woocommerce' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'version' => array(
+ 'description' => __( 'The core block template @version stamped on the post, or null when the email is not sync-enabled.', 'woocommerce' ),
+ 'type' => array( 'string', 'null' ),
+ 'readonly' => true,
+ ),
+ 'source_hash' => array(
+ 'description' => __( 'sha1 of the canonical block content stamped on the post, or null when the email is not sync-enabled.', 'woocommerce' ),
+ 'type' => array( 'string', 'null' ),
+ 'readonly' => true,
+ ),
+ 'synced_at' => array(
+ 'description' => __( 'UTC timestamp when the post was stamped (Y-m-d H:i:s), or null when the email is not sync-enabled.', 'woocommerce' ),
+ 'type' => array( 'string', 'null' ),
+ 'readonly' => true,
+ ),
+ 'status' => array(
+ 'description' => __( 'The post-reset sync status (in_sync on success for sync-enabled emails, null otherwise).', 'woocommerce' ),
+ 'type' => array( 'string', 'null' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Reset a `woo_email` post to its current core template render and (when sync-enabled) stamp sync meta.
+ *
+ * Writes the canonical post content (byte-identical to what
+ * {@see WCTransactionalEmailPostsGenerator} would produce on a fresh recreate). For emails
+ * that are opted in to template sync (registered in {@see WCEmailTemplateSyncRegistry}),
+ * also stamps `_wc_email_template_version`, `_wc_email_template_source_hash`,
+ * `_wc_email_last_synced_at`, and `_wc_email_template_status = in_sync`. Meta writes are
+ * conditional on the post update succeeding, so a `wp_update_post` failure leaves the
+ * post — and any pre-existing meta — untouched.
+ *
+ * Non-sync-enabled emails (e.g. third-party templates without an `@version` header)
+ * still receive a successful content reset, just without the meta stamp. This mirrors
+ * the pre-RSM-148 behaviour where the standalone REST PUT performed the content reset
+ * and stamping was a separate side effect, preserving backward compatibility.
+ *
+ * @param WP_REST_Request $request The REST request.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return WP_REST_Response|WP_Error
+ *
+ * @since 10.8.0
+ */
+ public function reset_response( WP_REST_Request $request ) {
+ if ( ! ( $this->post_manager && $this->posts_generator ) ) {
+ return new WP_Error(
+ 'woocommerce_email_editor_not_initialized',
+ __( 'Email editor is not initialized.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $post_id = (int) $request->get_param( 'id' );
+ $email_type = $this->post_manager->get_email_type_from_post_id( $post_id );
+ $email = $this->get_email_by_type( $email_type ?? '' );
+
+ if ( ! $email ) {
+ return new WP_Error(
+ 'woocommerce_email_not_found',
+ __( 'No email found for the given post ID.', 'woocommerce' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+ $sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( (string) $email_type );
+
+ $update_result = wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => $canonical,
+ ),
+ true
+ );
+
+ if ( is_wp_error( $update_result ) ) {
+ return new WP_Error(
+ 'woocommerce_email_reset_failed',
+ sprintf(
+ /* translators: %s: underlying error message */
+ __( 'Failed to reset email content: %s', 'woocommerce' ),
+ $update_result->get_error_message()
+ ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $source_hash = null;
+ $synced_at = null;
+ if ( null !== $sync_config ) {
+ $source_hash = sha1( $canonical );
+ $synced_at = gmdate( 'Y-m-d H:i:s' );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, (string) $sync_config['version'] );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $source_hash );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, $synced_at );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC );
+ }
+
+ return new WP_REST_Response(
+ array(
+ 'content' => $canonical,
+ 'version' => null !== $sync_config ? (string) $sync_config['version'] : null,
+ 'source_hash' => $source_hash,
+ 'synced_at' => $synced_at,
+ 'status' => null !== $sync_config ? WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC : null,
+ ),
+ 200
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
index b410a1967e0..fd592a6de1f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
@@ -6,6 +6,8 @@ namespace Automattic\WooCommerce\Tests\Internal\EmailEditor;
use Automattic\WooCommerce\Internal\EmailEditor\EmailApiController;
use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
@@ -63,6 +65,9 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
parent::tearDown();
update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
delete_option( 'woocommerce_' . $this->email_type . '_settings' );
+ remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+ WCEmailTemplateSyncRegistry::reset_cache();
+ delete_transient( 'wc_email_editor_initial_templates_generated' );
}
/**
@@ -338,4 +343,238 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
$this->assertArrayHasKey( 'content', $result->get_data() );
$this->assertSame( '<!-- wp:paragraph --><p>Default content</p><!-- /wp:paragraph -->', $result->get_data()['content'] );
}
+
+ /**
+ * @testdox Should reset post content to canonical core render and refresh sync meta.
+ */
+ public function test_reset_response_overwrites_post_content_and_stamps_sync_meta(): void {
+ $email_type = 'customer_new_account';
+
+ $generator = new WCTransactionalEmailPostsGenerator();
+ $generator->init_default_transactional_emails();
+
+ $post_manager = WCTransactionalEmailPostsManager::get_instance();
+ $post_manager->clear_caches();
+ $post_manager->delete_email_template( $email_type );
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ $post_id = $generator->generate_email_template_if_not_exists( $email_type );
+ $this->assertIsInt( $post_id );
+
+ // Simulate merchant customisation that diverges from the core render.
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => '<!-- wp:paragraph --><p>Customized by merchant</p><!-- /wp:paragraph -->',
+ )
+ );
+
+ // Backdate the synced_at stamp so we can assert the endpoint refreshes it.
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, '2000-01-01 00:00:00' );
+ // Force a non-in_sync status so we can assert the endpoint resets it.
+ update_post_meta(
+ $post_id,
+ WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED
+ );
+
+ $request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $post_id . '/reset' );
+ $request->set_param( 'id', $post_id );
+
+ $result = $this->email_api_controller->reset_response( $request );
+
+ $this->assertInstanceOf( \WP_REST_Response::class, $result );
+ $this->assertSame( 200, $result->get_status() );
+
+ $email = $this->resolve_wc_email( $email_type );
+ $this->assertNotNull( $email );
+ $expected_canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+
+ $response_data = $result->get_data();
+ $this->assertSame( $expected_canonical, $response_data['content'], 'Response content must equal canonical core render.' );
+ $this->assertSame( sha1( $expected_canonical ), $response_data['source_hash'], 'Response source_hash must equal sha1(canonical).' );
+ $this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC, $response_data['status'], 'Response status must be in_sync.' );
+ $this->assertNotEmpty( $response_data['version'], 'Response version must be populated.' );
+ $this->assertMatchesRegularExpression( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $response_data['synced_at'], 'Response synced_at must be a GMT timestamp.' );
+
+ $persisted_post = get_post( $post_id );
+ $this->assertSame( $expected_canonical, $persisted_post->post_content, 'Persisted post_content must equal canonical core render.' );
+
+ $this->assertSame(
+ $response_data['version'],
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ),
+ 'Persisted version meta must match response.'
+ );
+ $this->assertSame(
+ $response_data['source_hash'],
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+ 'Persisted source_hash meta must match response.'
+ );
+ $this->assertSame(
+ $response_data['synced_at'],
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ),
+ 'Persisted synced_at meta must match response.'
+ );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'Persisted status meta must be set to in_sync.'
+ );
+ }
+
+ /**
+ * @testdox Should return 404 when reset post ID has no associated email type.
+ */
+ public function test_reset_response_returns_404_for_unknown_post(): void {
+ $unassociated_post = $this->factory()->post->create_and_get(
+ array(
+ 'post_title' => 'Unknown Email',
+ 'post_name' => 'unknown_email_for_reset',
+ 'post_type' => Integration::EMAIL_POST_TYPE,
+ 'post_status' => 'draft',
+ )
+ );
+
+ $request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $unassociated_post->ID . '/reset' );
+ $request->set_param( 'id', $unassociated_post->ID );
+
+ $result = $this->email_api_controller->reset_response( $request );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'woocommerce_email_not_found', $result->get_error_code() );
+ $this->assertSame( 404, $result->get_error_data()['status'] );
+ }
+
+ /**
+ * @testdox Should reset content but skip meta stamping for emails absent from the sync registry.
+ */
+ public function test_reset_response_resets_content_without_meta_for_non_sync_enabled_email(): void {
+ $email_type = 'customer_new_account';
+
+ $generator = new WCTransactionalEmailPostsGenerator();
+ $generator->init_default_transactional_emails();
+
+ $post_manager = WCTransactionalEmailPostsManager::get_instance();
+ $post_manager->clear_caches();
+ $post_manager->delete_email_template( $email_type );
+
+ $post_id = $generator->generate_email_template_if_not_exists( $email_type );
+ $this->assertIsInt( $post_id );
+
+ // Capture meta stamped at generation time so we can assert it is unchanged after reset.
+ $baseline_version = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true );
+ $baseline_source_hash = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true );
+ $baseline_synced_at = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true );
+
+ // Simulate a customised post so the content reset is observable.
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => '<!-- wp:paragraph --><p>Customised by merchant</p><!-- /wp:paragraph -->',
+ )
+ );
+
+ // Forcibly empty the registry so the email is not sync-enabled.
+ WCEmailTemplateSyncRegistry::reset_cache();
+ add_filter( 'woocommerce_transactional_emails_for_block_editor', '__return_empty_array' );
+
+ $request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $post_id . '/reset' );
+ $request->set_param( 'id', $post_id );
+
+ $result = $this->email_api_controller->reset_response( $request );
+
+ $this->assertInstanceOf( \WP_REST_Response::class, $result );
+ $this->assertSame( 200, $result->get_status() );
+
+ $email = $this->resolve_wc_email( $email_type );
+ $this->assertNotNull( $email );
+ $expected_canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+
+ $response_data = $result->get_data();
+ $this->assertSame( $expected_canonical, $response_data['content'], 'Response content must equal canonical core render.' );
+ $this->assertNull( $response_data['version'], 'Response version must be null for non-sync-enabled emails.' );
+ $this->assertNull( $response_data['source_hash'], 'Response source_hash must be null for non-sync-enabled emails.' );
+ $this->assertNull( $response_data['synced_at'], 'Response synced_at must be null for non-sync-enabled emails.' );
+ $this->assertNull( $response_data['status'], 'Response status must be null for non-sync-enabled emails.' );
+
+ $this->assertSame(
+ $expected_canonical,
+ (string) get_post_field( 'post_content', $post_id ),
+ 'post_content must be reset to canonical render even when the email is not sync-enabled.'
+ );
+
+ // Stamping must NOT have run. Meta values stay at whatever the generator wrote at creation time.
+ $this->assertSame(
+ $baseline_version,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ),
+ '_wc_email_template_version must not be touched when the email is not sync-enabled.'
+ );
+ $this->assertSame(
+ $baseline_source_hash,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+ '_wc_email_template_source_hash must not be touched when the email is not sync-enabled.'
+ );
+ $this->assertSame(
+ $baseline_synced_at,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ),
+ '_wc_email_last_synced_at must not be touched when the email is not sync-enabled.'
+ );
+ $this->assertSame(
+ '',
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ '_wc_email_template_status must not be set when the email is not sync-enabled.'
+ );
+ }
+
+ /**
+ * @testdox Should return 500 when controller has not been initialized.
+ */
+ public function test_reset_response_returns_500_when_uninitialized(): void {
+ $controller = new EmailApiController();
+ // Intentionally skip init() to leave dependencies null.
+
+ $request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/0/reset' );
+ $request->set_param( 'id', 0 );
+
+ $result = $controller->reset_response( $request );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'woocommerce_email_editor_not_initialized', $result->get_error_code() );
+ $this->assertSame( 500, $result->get_error_data()['status'] );
+ }
+
+ /**
+ * @testdox Should register a POST /reset route alongside the existing default-content route.
+ */
+ public function test_register_routes_registers_reset_endpoint(): void {
+ $rest_server = rest_get_server();
+ $this->email_api_controller->register_routes();
+
+ $routes = $rest_server->get_routes();
+ $this->assertArrayHasKey( '/woocommerce-email-editor/v1/emails/(?P<id>\d+)/reset', $routes );
+
+ $reset_route_handlers = $routes['/woocommerce-email-editor/v1/emails/(?P<id>\d+)/reset'];
+ $methods = array();
+ foreach ( $reset_route_handlers as $handler ) {
+ foreach ( array_keys( $handler['methods'] ) as $method ) {
+ $methods[ $method ] = true;
+ }
+ }
+ $this->assertArrayHasKey( 'POST', $methods, 'Reset endpoint must accept POST.' );
+ }
+
+ /**
+ * Helper: resolve a WC_Email instance by email type ID.
+ *
+ * @param string $email_type Email type ID.
+ * @return \WC_Email|null
+ */
+ private function resolve_wc_email( string $email_type ): ?\WC_Email {
+ foreach ( WC()->mailer()->get_emails() as $email ) {
+ if ( $email->id === $email_type ) {
+ return $email;
+ }
+ }
+ return null;
+ }
}