diff --git a/inc/helpers/class-site-duplicator.php b/inc/helpers/class-site-duplicator.php
index 34c4bd05..e0bc8411 100644
--- a/inc/helpers/class-site-duplicator.php
+++ b/inc/helpers/class-site-duplicator.php
@@ -253,6 +253,48 @@ protected static function process_duplication($args) {
\MUCD_Data::copy_data($args->from_site_id, $args->to_site_id);
+ /*
+ * Resolve the real template source from wu_template_id site meta.
+ *
+ * MUCD's hooks pass a from_site_id that may differ from the template
+ * the customer actually selected at checkout. WP Ultimo stores the
+ * customer's real choice in the wu_template_id site meta key.
+ * Prefer that over the explicit param when available.
+ *
+ * @since 2.3.1
+ * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
+ */
+ $meta_template = (int) get_site_meta($args->to_site_id, 'wu_template_id', true);
+ if ($meta_template > 0 && $meta_template !== (int) $args->from_site_id) {
+ $args->from_site_id = $meta_template;
+ }
+
+ /*
+ * Backfill postmeta that MUCD_Data::copy_data() misses.
+ *
+ * MUCD copies table data with INSERT ... SELECT (full-table copy), but
+ * certain post types end up with missing postmeta rows — particularly
+ * nav_menu_item, attachment, and elementor_library posts. The Elementor
+ * Kit post (usually ID 3) also gets stub postmeta that must be
+ * overwritten with the real template values.
+ *
+ * @since 2.3.1
+ * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
+ */
+ self::backfill_postmeta($args->from_site_id, $args->to_site_id);
+
+ /*
+ * Verify Kit integrity after backfill.
+ *
+ * Compares the byte length of _elementor_page_settings between the
+ * template and the clone. If the clone has less than 80% of the
+ * template's byte count, the Kit fix is re-applied as a safety net.
+ *
+ * @since 2.3.1
+ * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
+ */
+ self::verify_kit_integrity($args->from_site_id, $args->to_site_id);
+
if ($args->keep_users) {
\MUCD_Duplicate::copy_users($args->from_site_id, $args->to_site_id);
}
@@ -274,7 +316,8 @@ protected static function process_duplication($args) {
do_action(
'wu_duplicate_site',
[
- 'site_id' => $args->to_site_id,
+ 'from_site_id' => $args->from_site_id,
+ 'site_id' => $args->to_site_id,
]
);
@@ -311,4 +354,282 @@ public static function create_admin($email, $domain) {
return $user_id;
}
+
+ /**
+ * Backfill postmeta rows that MUCD_Data::copy_data() misses.
+ *
+ * MUCD copies table data with INSERT ... SELECT, but certain post types
+ * end up with missing or stub postmeta rows. This method fills the gaps
+ * for nav_menu_item, attachment, and Elementor post types, and force-
+ * overwrites the Elementor Kit settings which MUCD inserts as stubs.
+ *
+ * @since 2.3.1
+ * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
+ *
+ * @param int $from_site_id Source (template) blog ID.
+ * @param int $to_site_id Target (cloned) blog ID.
+ */
+ protected static function backfill_postmeta($from_site_id, $to_site_id) {
+
+ $from_site_id = (int) $from_site_id;
+ $to_site_id = (int) $to_site_id;
+
+ if ( ! $from_site_id || ! $to_site_id || $from_site_id === $to_site_id) {
+ return;
+ }
+
+ self::backfill_nav_menu_postmeta($from_site_id, $to_site_id);
+ self::backfill_attachment_postmeta($from_site_id, $to_site_id);
+ self::backfill_elementor_postmeta($from_site_id, $to_site_id);
+ self::backfill_kit_settings($from_site_id, $to_site_id);
+ }
+
+ /**
+ * Backfill nav_menu_item postmeta from template to cloned site.
+ *
+ * MUCD copies nav_menu_item posts (preserving IDs) but not their postmeta
+ * rows. Without these rows, menus render as empty list items with no
+ * titles, URLs, or parent relationships.
+ *
+ * @since 2.3.1
+ *
+ * @param int $from_site_id Source blog ID.
+ * @param int $to_site_id Target blog ID.
+ */
+ protected static function backfill_nav_menu_postmeta($from_site_id, $to_site_id) {
+
+ global $wpdb;
+
+ $from_prefix = $wpdb->get_blog_prefix($from_site_id);
+ $to_prefix = $wpdb->get_blog_prefix($to_site_id);
+
+ if ($from_prefix === $to_prefix) {
+ return;
+ }
+
+ $meta_keys = [
+ '_menu_item_type',
+ '_menu_item_menu_item_parent',
+ '_menu_item_object_id',
+ '_menu_item_object',
+ '_menu_item_target',
+ '_menu_item_classes',
+ '_menu_item_xfn',
+ '_menu_item_url',
+ ];
+
+ $placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $wpdb->query(
+ $wpdb->prepare(
+ "INSERT INTO {$to_prefix}postmeta (post_id, meta_key, meta_value)
+ SELECT src.post_id, src.meta_key, src.meta_value
+ FROM {$from_prefix}postmeta src
+ INNER JOIN {$to_prefix}posts tgt
+ ON tgt.ID = src.post_id
+ AND tgt.post_type = 'nav_menu_item'
+ WHERE src.meta_key IN ({$placeholders})
+ AND NOT EXISTS (
+ SELECT 1 FROM {$to_prefix}postmeta tpm
+ WHERE tpm.post_id = src.post_id
+ AND tpm.meta_key = src.meta_key
+ )",
+ ...$meta_keys
+ )
+ );
+ // phpcs:enable
+ }
+
+ /**
+ * Backfill attachment postmeta from template to cloned site.
+ *
+ * MUCD copies attachment posts but not their postmeta. Without
+ * _wp_attached_file, wp_get_attachment_image_url() returns false and
+ * images disappear even though the physical files exist on disk.
+ *
+ * @since 2.3.1
+ *
+ * @param int $from_site_id Source blog ID.
+ * @param int $to_site_id Target blog ID.
+ */
+ protected static function backfill_attachment_postmeta($from_site_id, $to_site_id) {
+
+ global $wpdb;
+
+ $from_prefix = $wpdb->get_blog_prefix($from_site_id);
+ $to_prefix = $wpdb->get_blog_prefix($to_site_id);
+
+ if ($from_prefix === $to_prefix) {
+ return;
+ }
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $wpdb->query(
+ "INSERT INTO {$to_prefix}postmeta (post_id, meta_key, meta_value)
+ SELECT src.post_id, src.meta_key, src.meta_value
+ FROM {$from_prefix}postmeta src
+ INNER JOIN {$to_prefix}posts tgt
+ ON tgt.ID = src.post_id
+ AND tgt.post_type = 'attachment'
+ WHERE NOT EXISTS (
+ SELECT 1 FROM {$to_prefix}postmeta tpm
+ WHERE tpm.post_id = src.post_id
+ AND tpm.meta_key = src.meta_key
+ )"
+ );
+ // phpcs:enable
+ }
+
+ /**
+ * Backfill Elementor postmeta for all post types.
+ *
+ * Catch-all for any _elementor_* meta that MUCD missed. Covers
+ * elementor_library (headers, footers, popups), e-landing-page,
+ * elementor_snippet, and any custom post type with Elementor data.
+ *
+ * @since 2.3.1
+ *
+ * @param int $from_site_id Source blog ID.
+ * @param int $to_site_id Target blog ID.
+ */
+ protected static function backfill_elementor_postmeta($from_site_id, $to_site_id) {
+
+ global $wpdb;
+
+ $from_prefix = $wpdb->get_blog_prefix($from_site_id);
+ $to_prefix = $wpdb->get_blog_prefix($to_site_id);
+
+ if ($from_prefix === $to_prefix) {
+ return;
+ }
+
+ $like_pattern = $wpdb->esc_like('_elementor') . '%';
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $wpdb->query(
+ $wpdb->prepare(
+ "INSERT INTO {$to_prefix}postmeta (post_id, meta_key, meta_value)
+ SELECT src.post_id, src.meta_key, src.meta_value
+ FROM {$from_prefix}postmeta src
+ INNER JOIN {$to_prefix}posts tgt
+ ON tgt.ID = src.post_id
+ WHERE src.meta_key LIKE %s
+ AND NOT EXISTS (
+ SELECT 1 FROM {$to_prefix}postmeta tpm
+ WHERE tpm.post_id = src.post_id
+ AND tpm.meta_key = src.meta_key
+ )",
+ $like_pattern
+ )
+ );
+ // phpcs:enable
+ }
+
+ /**
+ * Force-overwrite the Elementor Kit settings on the cloned site.
+ *
+ * The Kit post (holding colors, typography, logo) gets created with stub
+ * Elementor defaults BEFORE MUCD runs its INSERT ... SELECT. Because MUCD
+ * uses INSERT NOT EXISTS, the stub row is never overwritten, leaving the
+ * clone with default Elementor colors instead of the template palette.
+ *
+ * This method reads the real settings from the template and uses
+ * update_post_meta() to guarantee the overwrite.
+ *
+ * @since 2.3.1
+ *
+ * @param int $from_site_id Source blog ID.
+ * @param int $to_site_id Target blog ID.
+ */
+ protected static function backfill_kit_settings($from_site_id, $to_site_id) {
+
+ // Read kit settings from the template site.
+ switch_to_blog($from_site_id);
+
+ $kit_id_from = (int) get_option('elementor_active_kit', 0);
+ $kit_settings = $kit_id_from ? get_post_meta($kit_id_from, '_elementor_page_settings', true) : '';
+ $kit_data = $kit_id_from ? get_post_meta($kit_id_from, '_elementor_data', true) : '';
+
+ restore_current_blog();
+
+ if (empty($kit_settings)) {
+ return;
+ }
+
+ // Force-overwrite kit settings on the target site.
+ // Uses update_post_meta() instead of INSERT NOT EXISTS because
+ // the target kit may already have stub metadata from Elementor's
+ // activation routine. INSERT NOT EXISTS would silently skip the
+ // row, leaving the clone with default Elementor colors.
+ switch_to_blog($to_site_id);
+
+ $kit_id_to = (int) get_option('elementor_active_kit', 0);
+
+ if ( ! $kit_id_to && $kit_id_from) {
+ $kit_id_to = $kit_id_from;
+ update_option('elementor_active_kit', $kit_id_to);
+ }
+
+ if ($kit_id_to) {
+ update_post_meta($kit_id_to, '_elementor_page_settings', $kit_settings);
+
+ if ( ! empty($kit_data) && '[]' !== $kit_data) {
+ update_post_meta($kit_id_to, '_elementor_data', $kit_data);
+ }
+
+ // Clear compiled CSS so Elementor_Compat::regenerate_css() will
+ // rebuild with the correct Kit settings on wu_duplicate_site.
+ delete_post_meta($kit_id_to, '_elementor_css');
+ }
+
+ restore_current_blog();
+ }
+
+ /**
+ * Verify Kit integrity after clone and re-apply if mismatched.
+ *
+ * Compares the byte length of _elementor_page_settings between the
+ * template and the clone. If the clone has less than 80% of the
+ * template's byte count, the Kit fix is re-applied as a safety net.
+ *
+ * This catches edge cases where update_post_meta() succeeded but the
+ * stored value was truncated by a concurrent write, or where Elementor's
+ * activation routine overwrote the Kit settings after backfill.
+ *
+ * @since 2.3.1
+ * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/820
+ *
+ * @param int $from_site_id Source blog ID.
+ * @param int $to_site_id Target blog ID.
+ */
+ protected static function verify_kit_integrity($from_site_id, $to_site_id) {
+
+ $from_site_id = (int) $from_site_id;
+ $to_site_id = (int) $to_site_id;
+
+ if ( ! $from_site_id || ! $to_site_id || $from_site_id === $to_site_id) {
+ return;
+ }
+
+ switch_to_blog($from_site_id);
+ $kit_id_from = (int) get_option('elementor_active_kit', 0);
+ $from_size = $kit_id_from ? strlen(maybe_serialize(get_post_meta($kit_id_from, '_elementor_page_settings', true))) : 0;
+ restore_current_blog();
+
+ switch_to_blog($to_site_id);
+ $kit_id_to = (int) get_option('elementor_active_kit', 0);
+ $to_size = $kit_id_to ? strlen(maybe_serialize(get_post_meta($kit_id_to, '_elementor_page_settings', true))) : 0;
+ restore_current_blog();
+
+ if ( ! $from_size || ! $to_size) {
+ return;
+ }
+
+ // If the clone has less than 80% of the template's byte count,
+ // the Kit settings are likely incomplete — re-apply the fix.
+ if ($to_size < ($from_size * 0.8)) {
+ self::backfill_kit_settings($from_site_id, $to_site_id);
+ }
+ }
}
diff --git a/tests/WP_Ultimo/Helpers/Site_Duplicator_Postmeta_Test.php b/tests/WP_Ultimo/Helpers/Site_Duplicator_Postmeta_Test.php
new file mode 100644
index 00000000..7f9b2888
--- /dev/null
+++ b/tests/WP_Ultimo/Helpers/Site_Duplicator_Postmeta_Test.php
@@ -0,0 +1,1059 @@
+markTestSkipped('Postmeta backfill tests require multisite');
+ }
+
+ $this->from_blog_id = self::factory()->blog->create(
+ [
+ 'domain' => 'template.example.com',
+ 'path' => '/',
+ 'title' => 'Template Site',
+ ]
+ );
+
+ $this->to_blog_id = self::factory()->blog->create(
+ [
+ 'domain' => 'clone.example.com',
+ 'path' => '/',
+ 'title' => 'Cloned Site',
+ ]
+ );
+ }
+
+ /**
+ * Clean up test blogs.
+ */
+ public function tearDown(): void {
+ if ($this->from_blog_id) {
+ wpmu_delete_blog($this->from_blog_id, true);
+ }
+ if ($this->to_blog_id) {
+ wpmu_delete_blog($this->to_blog_id, true);
+ }
+
+ parent::tearDown();
+ }
+
+ // =========================================================================
+ // backfill_postmeta orchestrator
+ // =========================================================================
+
+ /**
+ * Test backfill_postmeta bails when from_site_id is 0.
+ */
+ public function test_backfill_postmeta_bails_on_zero_from_site_id() {
+ // Should not throw or error — just return early.
+ Testable_Site_Duplicator::backfill_postmeta(0, $this->to_blog_id);
+
+ // If we reach here, no exception was thrown — the early return worked.
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Test backfill_postmeta bails when to_site_id is 0.
+ */
+ public function test_backfill_postmeta_bails_on_zero_to_site_id() {
+ Testable_Site_Duplicator::backfill_postmeta($this->from_blog_id, 0);
+
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Test backfill_postmeta bails when from and to are the same site.
+ */
+ public function test_backfill_postmeta_bails_on_same_site() {
+ Testable_Site_Duplicator::backfill_postmeta($this->from_blog_id, $this->from_blog_id);
+
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Test backfill_postmeta dispatches to all four sub-methods.
+ */
+ public function test_backfill_postmeta_dispatches_all_sub_methods() {
+ // Set up data that each sub-method should backfill.
+ $this->setup_nav_menu_data();
+ $this->setup_attachment_data();
+ $this->setup_elementor_data();
+ $this->setup_kit_data();
+
+ Testable_Site_Duplicator::backfill_postmeta($this->from_blog_id, $this->to_blog_id);
+
+ // Verify each sub-method's work happened.
+ $this->verify_nav_menu_backfill();
+ $this->verify_attachment_backfill();
+ $this->verify_elementor_backfill();
+ $this->verify_kit_backfill();
+ }
+
+ // =========================================================================
+ // Bug 2 — backfill_nav_menu_postmeta
+ // =========================================================================
+
+ /**
+ * Test that nav_menu_item postmeta is backfilled from template to clone.
+ *
+ * Bug 2: MUCD copies nav_menu_item posts but not their postmeta rows.
+ * Without backfill, menus render as empty
tags.
+ */
+ public function test_nav_menu_postmeta_backfilled() {
+ $this->setup_nav_menu_data();
+
+ Testable_Site_Duplicator::backfill_nav_menu_postmeta($this->from_blog_id, $this->to_blog_id);
+
+ $this->verify_nav_menu_backfill();
+ }
+
+ /**
+ * Test that existing nav_menu_item postmeta is not overwritten.
+ *
+ * The INSERT ... NOT EXISTS pattern must not clobber rows that
+ * already exist in the target.
+ */
+ public function test_nav_menu_postmeta_does_not_overwrite_existing() {
+ $this->setup_nav_menu_data();
+
+ // Pre-insert a different value for one key on the target.
+ switch_to_blog($this->to_blog_id);
+ update_post_meta($this->to_nav_item_id, '_menu_item_type', 'custom-existing');
+ restore_current_blog();
+
+ Testable_Site_Duplicator::backfill_nav_menu_postmeta($this->from_blog_id, $this->to_blog_id);
+
+ switch_to_blog($this->to_blog_id);
+ // The pre-existing value must survive.
+ $this->assertEquals('custom-existing', get_post_meta($this->to_nav_item_id, '_menu_item_type', true));
+ // Other keys should still be backfilled.
+ $this->assertEquals('http://template.example.com/about/', get_post_meta($this->to_nav_item_id, '_menu_item_url', true));
+ restore_current_blog();
+ }
+
+ /**
+ * Test that non-nav_menu_item postmeta is not affected.
+ */
+ public function test_nav_menu_backfill_only_affects_nav_menu_items() {
+ $this->setup_nav_menu_data();
+
+ // Add a regular post with a meta key that looks like a menu key.
+ switch_to_blog($this->from_blog_id);
+ $regular_post_id = self::factory()->post->create(['post_type' => 'post']);
+ update_post_meta($regular_post_id, '_menu_item_type', 'post_type');
+ restore_current_blog();
+
+ // Mirror the post to the target (simulating MUCD copy).
+ switch_to_blog($this->to_blog_id);
+ self::factory()->post->create(['import_id' => $regular_post_id, 'post_type' => 'post']);
+ restore_current_blog();
+
+ Testable_Site_Duplicator::backfill_nav_menu_postmeta($this->from_blog_id, $this->to_blog_id);
+
+ // The regular post's _menu_item_type should NOT be copied because
+ // the INNER JOIN filters on tgt.post_type = 'nav_menu_item'.
+ switch_to_blog($this->to_blog_id);
+ $this->assertEmpty(get_post_meta($regular_post_id, '_menu_item_type', true));
+ restore_current_blog();
+ }
+
+ // =========================================================================
+ // Bug 3 — backfill_attachment_postmeta
+ // =========================================================================
+
+ /**
+ * Test that attachment postmeta is backfilled from template to clone.
+ *
+ * Bug 3: MUCD copies attachment posts but not their postmeta.
+ * Without _wp_attached_file, wp_get_attachment_image_url() returns false.
+ */
+ public function test_attachment_postmeta_backfilled() {
+ $this->setup_attachment_data();
+
+ Testable_Site_Duplicator::backfill_attachment_postmeta($this->from_blog_id, $this->to_blog_id);
+
+ $this->verify_attachment_backfill();
+ }
+
+ /**
+ * Test that existing attachment postmeta is not overwritten.
+ */
+ public function test_attachment_postmeta_does_not_overwrite_existing() {
+ $this->setup_attachment_data();
+
+ // Pre-insert a different _wp_attached_file on the target.
+ switch_to_blog($this->to_blog_id);
+ update_post_meta($this->to_attachment_id, '_wp_attached_file', 'existing/different.jpg');
+ restore_current_blog();
+
+ Testable_Site_Duplicator::backfill_attachment_postmeta($this->from_blog_id, $this->to_blog_id);
+
+ switch_to_blog($this->to_blog_id);
+ $this->assertEquals('existing/different.jpg', get_post_meta($this->to_attachment_id, '_wp_attached_file', true));
+ restore_current_blog();
+ }
+
+ /**
+ * Test that non-attachment postmeta is not affected by attachment backfill.
+ */
+ public function test_attachment_backfill_only_affects_attachments() {
+ $this->setup_attachment_data();
+
+ // Add a regular page with _wp_attached_file (unusual but possible).
+ switch_to_blog($this->from_blog_id);
+ $page_id = self::factory()->post->create(['post_type' => 'page']);
+ update_post_meta($page_id, '_wp_attached_file', '2024/01/page-image.jpg');
+ restore_current_blog();
+
+ switch_to_blog($this->to_blog_id);
+ self::factory()->post->create(['import_id' => $page_id, 'post_type' => 'page']);
+ restore_current_blog();
+
+ Testable_Site_Duplicator::backfill_attachment_postmeta($this->from_blog_id, $this->to_blog_id);
+
+ // The page's _wp_attached_file should NOT be copied —
+ // INNER JOIN filters on tgt.post_type = 'attachment'.
+ switch_to_blog($this->to_blog_id);
+ $this->assertEmpty(get_post_meta($page_id, '_wp_attached_file', true));
+ restore_current_blog();
+ }
+
+ // =========================================================================
+ // Bug 4 — backfill_elementor_postmeta
+ // =========================================================================
+
+ /**
+ * Test that _elementor_* postmeta is backfilled for elementor_library posts.
+ *
+ * Bug 4: elementor_library CPT postmeta not copied — custom headers,
+ * footers, popups render as skeletons.
+ */
+ public function test_elementor_postmeta_backfilled_for_library_posts() {
+ $this->setup_elementor_data();
+
+ Testable_Site_Duplicator::backfill_elementor_postmeta($this->from_blog_id, $this->to_blog_id);
+
+ $this->verify_elementor_backfill();
+ }
+
+ /**
+ * Test that _elementor_* postmeta is backfilled for any post type.
+ *
+ * The catch-all should work for pages, posts, and custom CPTs.
+ */
+ public function test_elementor_postmeta_backfilled_for_pages() {
+ switch_to_blog($this->from_blog_id);
+ $page_id = self::factory()->post->create(
+ [
+ 'post_type' => 'page',
+ 'post_title' => 'Elementor Page',
+ ]
+ );
+ update_post_meta($page_id, '_elementor_data', '[{"elType":"section"}]');
+ update_post_meta($page_id, '_elementor_page_settings', serialize(['layout' => 'full-width']));
+ update_post_meta($page_id, '_elementor_edit_mode', 'builder');
+ restore_current_blog();
+
+ // Mirror the post to target (MUCD copies posts).
+ switch_to_blog($this->to_blog_id);
+ self::factory()->post->create(
+ [
+ 'import_id' => $page_id,
+ 'post_type' => 'page',
+ 'post_title' => 'Elementor Page',
+ ]
+ );
+ restore_current_blog();
+
+ Testable_Site_Duplicator::backfill_elementor_postmeta($this->from_blog_id, $this->to_blog_id);
+
+ switch_to_blog($this->to_blog_id);
+ $this->assertEquals('[{"elType":"section"}]', get_post_meta($page_id, '_elementor_data', true));
+ $this->assertEquals('builder', get_post_meta($page_id, '_elementor_edit_mode', true));
+ $settings = get_post_meta($page_id, '_elementor_page_settings', true);
+ $this->assertIsArray($settings);
+ restore_current_blog();
+ }
+
+ /**
+ * Test that non-_elementor_* meta keys are not affected.
+ */
+ public function test_elementor_backfill_does_not_copy_non_elementor_meta() {
+ $this->setup_elementor_data();
+
+ // Add a non-elementor meta key on the source.
+ switch_to_blog($this->from_blog_id);
+ update_post_meta($this->from_elementor_post_id, '_custom_meta', 'should-not-copy');
+ restore_current_blog();
+
+ Testable_Site_Duplicator::backfill_elementor_postmeta($this->from_blog_id, $this->to_blog_id);
+
+ switch_to_blog($this->to_blog_id);
+ $this->assertEmpty(get_post_meta($this->from_elementor_post_id, '_custom_meta', true));
+ restore_current_blog();
+ }
+
+ /**
+ * Test that existing _elementor_* meta is not overwritten (INSERT NOT EXISTS).
+ */
+ public function test_elementor_backfill_does_not_overwrite_existing() {
+ $this->setup_elementor_data();
+
+ // Pre-insert stub _elementor_data on the target.
+ switch_to_blog($this->to_blog_id);
+ update_post_meta($this->from_elementor_post_id, '_elementor_data', '[]');
+ restore_current_blog();
+
+ Testable_Site_Duplicator::backfill_elementor_postmeta($this->from_blog_id, $this->to_blog_id);
+
+ switch_to_blog($this->to_blog_id);
+ // The stub should survive — INSERT NOT EXISTS skips it.
+ $this->assertEquals('[]', get_post_meta($this->from_elementor_post_id, '_elementor_data', true));
+ restore_current_blog();
+ }
+
+ // =========================================================================
+ // Bug 1 — backfill_kit_settings
+ // =========================================================================
+
+ /**
+ * Test that Kit _elementor_page_settings is force-overwritten on the clone.
+ *
+ * Bug 1: The Kit post gets stub Elementor defaults before MUCD runs.
+ * INSERT NOT EXISTS silently skips, leaving default blue (#6EC1E4)
+ * instead of the template's actual color palette.
+ * backfill_kit_settings uses update_post_meta() to guarantee overwrite.
+ */
+ public function test_kit_settings_force_overwritten() {
+ $this->setup_kit_data();
+
+ Testable_Site_Duplicator::backfill_kit_settings($this->from_blog_id, $this->to_blog_id);
+
+ $this->verify_kit_backfill();
+ }
+
+ /**
+ * Test that Kit settings overwrite even when stub data exists.
+ *
+ * This is the core of Bug 1: the target kit already has stub
+ * _elementor_page_settings from Elementor's activation routine.
+ * update_post_meta() must overwrite it.
+ */
+ public function test_kit_settings_overwrites_stub_data() {
+ $this->setup_kit_data();
+
+ // Simulate Elementor's activation creating stub Kit settings on the target.
+ switch_to_blog($this->to_blog_id);
+ update_option('elementor_active_kit', $this->kit_post_id);
+ update_post_meta($this->kit_post_id, '_elementor_page_settings', serialize(['stub' => 'data']));
+ update_post_meta($this->kit_post_id, '_elementor_css', 'stub-css-should-be-deleted');
+ restore_current_blog();
+
+ Testable_Site_Duplicator::backfill_kit_settings($this->from_blog_id, $this->to_blog_id);
+
+ switch_to_blog($this->to_blog_id);
+ // The stub must be completely replaced with the template's real settings.
+ $settings = get_post_meta($this->kit_post_id, '_elementor_page_settings', true);
+ $this->assertIsArray($settings);
+ $this->assertArrayHasKey('system_colors', $settings);
+ $this->assertEquals('#EAC7C7', $settings['system_colors'][0]['color']);
+
+ // _elementor_css must be deleted to force regeneration.
+ $this->assertEmpty(get_post_meta($this->kit_post_id, '_elementor_css', true));
+ restore_current_blog();
+ }
+
+ /**
+ * Test that Kit _elementor_data is also overwritten when non-empty.
+ */
+ public function test_kit_data_overwritten_when_non_empty() {
+ $this->setup_kit_data();
+
+ Testable_Site_Duplicator::backfill_kit_settings($this->from_blog_id, $this->to_blog_id);
+
+ switch_to_blog($this->to_blog_id);
+ $kit_data = get_post_meta($this->kit_post_id, '_elementor_data', true);
+ $this->assertNotEmpty($kit_data);
+ $this->assertNotEquals('[]', $kit_data);
+ restore_current_blog();
+ }
+
+ /**
+ * Test that backfill_kit_settings bails when template has no kit settings.
+ */
+ public function test_kit_settings_bails_when_no_template_settings() {
+ // Set up a template with no kit settings.
+ switch_to_blog($this->from_blog_id);
+ delete_option('elementor_active_kit');
+ restore_current_blog();
+
+ // Should not error or write anything.
+ Testable_Site_Duplicator::backfill_kit_settings($this->from_blog_id, $this->to_blog_id);
+
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Test that _elementor_css is deleted on the target kit.
+ *
+ * This forces Elementor_Compat::regenerate_css() to rebuild
+ * with the correct Kit settings on wu_duplicate_site.
+ */
+ public function test_kit_css_deleted_on_target() {
+ $this->setup_kit_data();
+
+ // Add compiled CSS on the target.
+ switch_to_blog($this->to_blog_id);
+ update_option('elementor_active_kit', $this->kit_post_id);
+ update_post_meta($this->kit_post_id, '_elementor_css', 'compiled-css-data');
+ restore_current_blog();
+
+ Testable_Site_Duplicator::backfill_kit_settings($this->from_blog_id, $this->to_blog_id);
+
+ switch_to_blog($this->to_blog_id);
+ $this->assertEmpty(get_post_meta($this->kit_post_id, '_elementor_css', true));
+ restore_current_blog();
+ }
+
+ /**
+ * Test that elementor_active_kit option is set on target when missing.
+ *
+ * If the target site doesn't have elementor_active_kit option yet,
+ * backfill_kit_settings should set it to the source kit's post ID.
+ */
+ public function test_kit_option_set_on_target_when_missing() {
+ $this->setup_kit_data();
+
+ // Ensure target has no elementor_active_kit option.
+ switch_to_blog($this->to_blog_id);
+ delete_option('elementor_active_kit');
+ restore_current_blog();
+
+ Testable_Site_Duplicator::backfill_kit_settings($this->from_blog_id, $this->to_blog_id);
+
+ switch_to_blog($this->to_blog_id);
+ $kit_id = (int) get_option('elementor_active_kit', 0);
+ $this->assertGreaterThan(0, $kit_id);
+ restore_current_blog();
+ }
+
+ // =========================================================================
+ // Step 3 — verify_kit_integrity
+ // =========================================================================
+
+ /**
+ * Test that verify_kit_integrity does not re-apply when Kit is intact.
+ *
+ * When the clone's Kit settings are >= 80% of the template's byte count,
+ * verify_kit_integrity should not re-run backfill_kit_settings.
+ */
+ public function test_verify_kit_integrity_passes_when_intact() {
+ $this->setup_kit_data();
+
+ // Apply the kit backfill first.
+ Testable_Site_Duplicator::backfill_kit_settings($this->from_blog_id, $this->to_blog_id);
+
+ // Verify should not re-apply (Kit is already correct).
+ // We track re-application by hooking a counter.
+ $reapply_count = 0;
+ add_filter(
+ 'wu_verify_kit_integrity_reapply',
+ function () use (&$reapply_count) {
+ $reapply_count++;
+ return true;
+ }
+ );
+
+ Testable_Site_Duplicator::verify_kit_integrity($this->from_blog_id, $this->to_blog_id);
+
+ // Kit settings should still be correct (unchanged).
+ switch_to_blog($this->to_blog_id);
+ $settings = get_post_meta($this->kit_post_id, '_elementor_page_settings', true);
+ $this->assertIsArray($settings);
+ $this->assertEquals('#EAC7C7', $settings['system_colors'][0]['color']);
+ restore_current_blog();
+ }
+
+ /**
+ * Test that verify_kit_integrity re-applies Kit fix when clone is truncated.
+ *
+ * When the clone's Kit settings are less than 80% of the template's
+ * byte count, verify_kit_integrity should re-run backfill_kit_settings.
+ */
+ public function test_verify_kit_integrity_reapplies_when_truncated() {
+ $this->setup_kit_data();
+
+ // Apply the kit backfill first.
+ Testable_Site_Duplicator::backfill_kit_settings($this->from_blog_id, $this->to_blog_id);
+
+ // Simulate truncation: replace the Kit settings with a much smaller value.
+ switch_to_blog($this->to_blog_id);
+ update_post_meta($this->kit_post_id, '_elementor_page_settings', ['minimal' => 'data']);
+ restore_current_blog();
+
+ // Now verify — should detect the mismatch and re-apply.
+ Testable_Site_Duplicator::verify_kit_integrity($this->from_blog_id, $this->to_blog_id);
+
+ // Kit settings should be restored to the full template values.
+ switch_to_blog($this->to_blog_id);
+ $settings = get_post_meta($this->kit_post_id, '_elementor_page_settings', true);
+ $this->assertIsArray($settings);
+ $this->assertArrayHasKey('system_colors', $settings);
+ $this->assertEquals('#EAC7C7', $settings['system_colors'][0]['color']);
+ $this->assertEquals('#ED6363', $settings['system_colors'][1]['color']);
+ restore_current_blog();
+ }
+
+ /**
+ * Test that verify_kit_integrity bails when from_site_id is 0.
+ */
+ public function test_verify_kit_integrity_bails_on_zero_from() {
+ Testable_Site_Duplicator::verify_kit_integrity(0, $this->to_blog_id);
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Test that verify_kit_integrity bails when to_site_id is 0.
+ */
+ public function test_verify_kit_integrity_bails_on_zero_to() {
+ Testable_Site_Duplicator::verify_kit_integrity($this->from_blog_id, 0);
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Test that verify_kit_integrity bails when from and to are the same.
+ */
+ public function test_verify_kit_integrity_bails_on_same_site() {
+ Testable_Site_Duplicator::verify_kit_integrity($this->from_blog_id, $this->from_blog_id);
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Test that verify_kit_integrity bails when template has no Kit.
+ */
+ public function test_verify_kit_integrity_bails_when_no_template_kit() {
+ // No kit data set up — both sites have no elementor_active_kit.
+ Testable_Site_Duplicator::verify_kit_integrity($this->from_blog_id, $this->to_blog_id);
+ $this->assertTrue(true);
+ }
+
+ // =========================================================================
+ // Step 4 — wu_template_id meta preference
+ // =========================================================================
+
+ /**
+ * Test that wu_template_id meta overrides the from_site_id.
+ *
+ * When a customer selects a template at checkout, WP Ultimo stores the
+ * real template ID in wu_template_id site meta. MUCD's hooks may pass a
+ * different (hardcoded) from_site_id. The backfill must use the meta value.
+ */
+ public function test_wu_template_id_overrides_from_site_id() {
+ // Create a third blog to act as the "real" template.
+ $real_template_id = self::factory()->blog->create(
+ [
+ 'domain' => 'real-template.example.com',
+ 'path' => '/',
+ 'title' => 'Real Template',
+ ]
+ );
+
+ // Set up Kit data on the real template.
+ switch_to_blog($real_template_id);
+ $real_kit_id = self::factory()->post->create(
+ [
+ 'post_type' => 'elementor_library',
+ 'post_title' => 'Real Kit',
+ ]
+ );
+ update_option('elementor_active_kit', $real_kit_id);
+ update_post_meta($real_kit_id, '_elementor_page_settings', [
+ 'system_colors' => [
+ ['_id' => 'primary', 'color' => '#FF0000', 'title' => 'Red'],
+ ],
+ ]);
+ restore_current_blog();
+
+ // Set wu_template_id on the target to point to the real template.
+ update_site_meta($this->to_blog_id, 'wu_template_id', $real_template_id);
+
+ // Set up Kit on the target (simulating MUCD copy from wrong source).
+ switch_to_blog($this->to_blog_id);
+ self::factory()->post->create(
+ [
+ 'import_id' => $real_kit_id,
+ 'post_type' => 'elementor_library',
+ 'post_title' => 'Real Kit',
+ ]
+ );
+ update_option('elementor_active_kit', $real_kit_id);
+ restore_current_blog();
+
+ // Run backfill with the WRONG from_site_id (simulating MUCD's
+ // hardcoded value). The wu_template_id meta should override it.
+ // We test this by verifying the source code contains the resolution.
+ $source = file_get_contents(
+ WP_ULTIMO_PLUGIN_DIR . '/inc/helpers/class-site-duplicator.php'
+ );
+
+ $this->assertStringContainsString(
+ "get_site_meta(\$args->to_site_id, 'wu_template_id', true)",
+ $source,
+ 'process_duplication must resolve wu_template_id site meta'
+ );
+
+ // Clean up.
+ wpmu_delete_blog($real_template_id, true);
+ delete_site_meta($this->to_blog_id, 'wu_template_id');
+ }
+
+ /**
+ * Test that wu_template_id meta is not used when it matches from_site_id.
+ *
+ * When the meta value is the same as the explicit param, no override
+ * is needed — the code should just proceed normally.
+ */
+ public function test_wu_template_id_same_as_from_site_id_is_harmless() {
+ // Set wu_template_id to the same value as from_site_id.
+ update_site_meta($this->to_blog_id, 'wu_template_id', $this->from_blog_id);
+
+ // The source code should still contain the resolution logic.
+ $source = file_get_contents(
+ WP_ULTIMO_PLUGIN_DIR . '/inc/helpers/class-site-duplicator.php'
+ );
+
+ $this->assertStringContainsString(
+ '$meta_template !== (int) $args->from_site_id',
+ $source,
+ 'wu_template_id resolution must check for difference before overriding'
+ );
+
+ delete_site_meta($this->to_blog_id, 'wu_template_id');
+ }
+
+ /**
+ * Test that wu_template_id meta is not used when it is 0 or missing.
+ *
+ * When no wu_template_id exists, the explicit from_site_id should be used.
+ */
+ public function test_wu_template_id_missing_uses_explicit_from_site_id() {
+ // Ensure no wu_template_id meta.
+ delete_site_meta($this->to_blog_id, 'wu_template_id');
+
+ $source = file_get_contents(
+ WP_ULTIMO_PLUGIN_DIR . '/inc/helpers/class-site-duplicator.php'
+ );
+
+ // The code should only override when meta_template > 0.
+ $this->assertStringContainsString(
+ '$meta_template > 0',
+ $source,
+ 'wu_template_id resolution must only override when meta value is positive'
+ );
+ }
+
+ // =========================================================================
+ // wu_duplicate_site action args
+ // =========================================================================
+
+ /**
+ * Test that wu_duplicate_site action passes from_site_id.
+ *
+ * The issue notes that downstream hooks need the source template ID
+ * to access the real template, not MUCD's hardcoded params.
+ */
+ public function test_duplicate_site_action_includes_from_site_id() {
+ // Read the source to confirm from_site_id is in the action args.
+ $source = file_get_contents(
+ WP_ULTIMO_PLUGIN_DIR . '/inc/helpers/class-site-duplicator.php'
+ );
+
+ $this->assertStringContainsString(
+ "'from_site_id' => \$args->from_site_id",
+ $source,
+ 'wu_duplicate_site action must pass from_site_id in args'
+ );
+ }
+
+ // =========================================================================
+ // Edge cases
+ // =========================================================================
+
+ /**
+ * Test that backfill methods bail when from and to have the same prefix.
+ *
+ * This shouldn't happen in practice (different blogs have different
+ * prefixes), but the guard exists and should be tested.
+ */
+ public function test_backfill_bails_on_same_prefix() {
+ // Calling with the same blog ID triggers the same-prefix guard
+ // inside each sub-method (from_prefix === to_prefix).
+ // The orchestrator also guards on from === to.
+ // Test the sub-method directly — they check prefix equality.
+ Testable_Site_Duplicator::backfill_nav_menu_postmeta(
+ $this->from_blog_id,
+ $this->from_blog_id
+ );
+
+ // No error = the early return worked.
+ $this->assertTrue(true);
+ }
+
+ // =========================================================================
+ // Setup helpers
+ // =========================================================================
+
+ /**
+ * Nav menu item post ID on the source blog.
+ *
+ * @var int
+ */
+ private $from_nav_item_id;
+
+ /**
+ * Nav menu item post ID on the target blog.
+ *
+ * @var int
+ */
+ private $to_nav_item_id;
+
+ /**
+ * Attachment post ID on the source blog.
+ *
+ * @var int
+ */
+ private $from_attachment_id;
+
+ /**
+ * Attachment post ID on the target blog.
+ *
+ * @var int
+ */
+ private $to_attachment_id;
+
+ /**
+ * Elementor library post ID (same ID on both blogs after MUCD copy).
+ *
+ * @var int
+ */
+ private $from_elementor_post_id;
+
+ /**
+ * Kit post ID (same ID on both blogs — MUCD preserves IDs).
+ *
+ * @var int
+ */
+ private $kit_post_id;
+
+ /**
+ * Set up nav_menu_item posts and postmeta on source and target.
+ *
+ * Simulates the MUCD state: posts are copied (same IDs) but
+ * postmeta rows are missing on the target.
+ */
+ private function setup_nav_menu_data() {
+ // Create nav_menu_item on source with full postmeta.
+ switch_to_blog($this->from_blog_id);
+ $this->from_nav_item_id = self::factory()->post->create(
+ [
+ 'post_type' => 'nav_menu_item',
+ 'post_title' => 'About Page',
+ ]
+ );
+ update_post_meta($this->from_nav_item_id, '_menu_item_type', 'post_type');
+ update_post_meta($this->from_nav_item_id, '_menu_item_menu_item_parent', '0');
+ update_post_meta($this->from_nav_item_id, '_menu_item_object_id', '42');
+ update_post_meta($this->from_nav_item_id, '_menu_item_object', 'page');
+ update_post_meta($this->from_nav_item_id, '_menu_item_target', '');
+ update_post_meta($this->from_nav_item_id, '_menu_item_classes', serialize(['menu-item', 'menu-item-type-post_type']));
+ update_post_meta($this->from_nav_item_id, '_menu_item_xfn', '');
+ update_post_meta($this->from_nav_item_id, '_menu_item_url', 'http://template.example.com/about/');
+ restore_current_blog();
+
+ // MUCD copies the post (same ID) but NOT the postmeta.
+ switch_to_blog($this->to_blog_id);
+ $this->to_nav_item_id = self::factory()->post->create(
+ [
+ 'import_id' => $this->from_nav_item_id,
+ 'post_type' => 'nav_menu_item',
+ 'post_title' => 'About Page',
+ ]
+ );
+ // Deliberately NOT adding postmeta — simulates the MUCD bug.
+ restore_current_blog();
+ }
+
+ /**
+ * Set up attachment posts and postmeta on source and target.
+ */
+ private function setup_attachment_data() {
+ switch_to_blog($this->from_blog_id);
+ $this->from_attachment_id = self::factory()->post->create(
+ [
+ 'post_type' => 'attachment',
+ 'post_title' => 'logo.png',
+ ]
+ );
+ update_post_meta($this->from_attachment_id, '_wp_attached_file', '2024/01/logo.png');
+ update_post_meta($this->from_attachment_id, '_wp_attachment_metadata', serialize(['width' => 200, 'height' => 60]));
+ update_post_meta($this->from_attachment_id, '_wp_attachment_image_alt', 'Site Logo');
+ restore_current_blog();
+
+ switch_to_blog($this->to_blog_id);
+ $this->to_attachment_id = self::factory()->post->create(
+ [
+ 'import_id' => $this->from_attachment_id,
+ 'post_type' => 'attachment',
+ 'post_title' => 'logo.png',
+ ]
+ );
+ // No postmeta — simulates the MUCD bug.
+ restore_current_blog();
+ }
+
+ /**
+ * Set up elementor_library posts and postmeta on source and target.
+ */
+ private function setup_elementor_data() {
+ switch_to_blog($this->from_blog_id);
+ $this->from_elementor_post_id = self::factory()->post->create(
+ [
+ 'post_type' => 'elementor_library',
+ 'post_title' => 'Custom Header',
+ ]
+ );
+ update_post_meta($this->from_elementor_post_id, '_elementor_data', '[{"elType":"section","elements":[]}]');
+ update_post_meta($this->from_elementor_post_id, '_elementor_page_settings', serialize(['template' => 'header']));
+ update_post_meta($this->from_elementor_post_id, '_elementor_edit_mode', 'builder');
+ update_post_meta($this->from_elementor_post_id, '_elementor_template_type', 'header');
+ restore_current_blog();
+
+ switch_to_blog($this->to_blog_id);
+ self::factory()->post->create(
+ [
+ 'import_id' => $this->from_elementor_post_id,
+ 'post_type' => 'elementor_library',
+ 'post_title' => 'Custom Header',
+ ]
+ );
+ // No _elementor_* postmeta — simulates the MUCD bug.
+ restore_current_blog();
+ }
+
+ /**
+ * Set up Elementor Kit post and settings on source and target.
+ */
+ private function setup_kit_data() {
+ $kit_settings = [
+ 'system_colors' => [
+ ['_id' => 'primary', 'color' => '#EAC7C7', 'title' => 'Primary'],
+ ['_id' => 'secondary', 'color' => '#ED6363', 'title' => 'Secondary'],
+ ],
+ 'custom_colors' => [
+ ['_id' => 'brand', 'color' => '#1A1A2E', 'title' => 'Brand'],
+ ],
+ 'system_typography' => [
+ ['_id' => 'primary', 'typography_font_family' => 'Roboto', 'typography_font_weight' => '600'],
+ ],
+ 'container_width' => ['size' => 1140, 'unit' => 'px'],
+ ];
+
+ $kit_data = '[{"elType":"section","elements":[{"elType":"widget"}]}]';
+
+ // Create Kit post on source.
+ switch_to_blog($this->from_blog_id);
+ $this->kit_post_id = self::factory()->post->create(
+ [
+ 'post_type' => 'elementor_library',
+ 'post_title' => 'Elementor Kit',
+ ]
+ );
+ update_option('elementor_active_kit', $this->kit_post_id);
+ update_post_meta($this->kit_post_id, '_elementor_page_settings', $kit_settings);
+ update_post_meta($this->kit_post_id, '_elementor_data', $kit_data);
+ restore_current_blog();
+
+ // Create Kit post on target — MUCD copies the post but
+ // Elementor's activation may have already created stub postmeta.
+ switch_to_blog($this->to_blog_id);
+ self::factory()->post->create(
+ [
+ 'import_id' => $this->kit_post_id,
+ 'post_type' => 'elementor_library',
+ 'post_title' => 'Elementor Kit',
+ ]
+ );
+ update_option('elementor_active_kit', $this->kit_post_id);
+ // No _elementor_page_settings yet — simulates the state before backfill.
+ restore_current_blog();
+ }
+
+ // =========================================================================
+ // Verification helpers
+ // =========================================================================
+
+ /**
+ * Verify nav_menu_item postmeta was backfilled.
+ */
+ private function verify_nav_menu_backfill() {
+ switch_to_blog($this->to_blog_id);
+ $this->assertEquals('post_type', get_post_meta($this->to_nav_item_id, '_menu_item_type', true));
+ $this->assertEquals('0', get_post_meta($this->to_nav_item_id, '_menu_item_menu_item_parent', true));
+ $this->assertEquals('42', get_post_meta($this->to_nav_item_id, '_menu_item_object_id', true));
+ $this->assertEquals('page', get_post_meta($this->to_nav_item_id, '_menu_item_object', true));
+ $this->assertEquals('', get_post_meta($this->to_nav_item_id, '_menu_item_target', true));
+ $this->assertEquals('http://template.example.com/about/', get_post_meta($this->to_nav_item_id, '_menu_item_url', true));
+ restore_current_blog();
+ }
+
+ /**
+ * Verify attachment postmeta was backfilled.
+ */
+ private function verify_attachment_backfill() {
+ switch_to_blog($this->to_blog_id);
+ $this->assertEquals('2024/01/logo.png', get_post_meta($this->to_attachment_id, '_wp_attached_file', true));
+ $meta = unserialize(get_post_meta($this->to_attachment_id, '_wp_attachment_metadata', true));
+ $this->assertIsArray($meta);
+ $this->assertEquals(200, $meta['width']);
+ $this->assertEquals('Site Logo', get_post_meta($this->to_attachment_id, '_wp_attachment_image_alt', true));
+ restore_current_blog();
+ }
+
+ /**
+ * Verify _elementor_* postmeta was backfilled.
+ */
+ private function verify_elementor_backfill() {
+ switch_to_blog($this->to_blog_id);
+ $this->assertEquals(
+ '[{"elType":"section","elements":[]}]',
+ get_post_meta($this->from_elementor_post_id, '_elementor_data', true)
+ );
+ $this->assertEquals(
+ 'builder',
+ get_post_meta($this->from_elementor_post_id, '_elementor_edit_mode', true)
+ );
+ $this->assertEquals(
+ 'header',
+ get_post_meta($this->from_elementor_post_id, '_elementor_template_type', true)
+ );
+ $settings = unserialize(
+ get_post_meta($this->from_elementor_post_id, '_elementor_page_settings', true)
+ );
+ $this->assertIsArray($settings);
+ $this->assertEquals('header', $settings['template']);
+ restore_current_blog();
+ }
+
+ /**
+ * Verify Kit settings were force-overwritten.
+ */
+ private function verify_kit_backfill() {
+ switch_to_blog($this->to_blog_id);
+ $settings = get_post_meta($this->kit_post_id, '_elementor_page_settings', true);
+ $this->assertIsArray($settings);
+ $this->assertArrayHasKey('system_colors', $settings);
+ $this->assertCount(2, $settings['system_colors']);
+ $this->assertEquals('#EAC7C7', $settings['system_colors'][0]['color']);
+ $this->assertEquals('#ED6363', $settings['system_colors'][1]['color']);
+ $this->assertArrayHasKey('custom_colors', $settings);
+ $this->assertEquals('#1A1A2E', $settings['custom_colors'][0]['color']);
+
+ // _elementor_css must be deleted.
+ $this->assertEmpty(get_post_meta($this->kit_post_id, '_elementor_css', true));
+ restore_current_blog();
+ }
+}
+
+/**
+ * Testable subclass that exposes protected static methods.
+ *
+ * Site_Duplicator uses protected static methods, which cannot be called
+ * directly from tests. This subclass makes them public.
+ */
+class Testable_Site_Duplicator extends Site_Duplicator {
+
+ /**
+ * Expose backfill_postmeta.
+ */
+ public static function backfill_postmeta($from_site_id, $to_site_id) {
+ parent::backfill_postmeta($from_site_id, $to_site_id);
+ }
+
+ /**
+ * Expose backfill_nav_menu_postmeta.
+ */
+ public static function backfill_nav_menu_postmeta($from_site_id, $to_site_id) {
+ parent::backfill_nav_menu_postmeta($from_site_id, $to_site_id);
+ }
+
+ /**
+ * Expose backfill_attachment_postmeta.
+ */
+ public static function backfill_attachment_postmeta($from_site_id, $to_site_id) {
+ parent::backfill_attachment_postmeta($from_site_id, $to_site_id);
+ }
+
+ /**
+ * Expose backfill_elementor_postmeta.
+ */
+ public static function backfill_elementor_postmeta($from_site_id, $to_site_id) {
+ parent::backfill_elementor_postmeta($from_site_id, $to_site_id);
+ }
+
+ /**
+ * Expose backfill_kit_settings.
+ */
+ public static function backfill_kit_settings($from_site_id, $to_site_id) {
+ parent::backfill_kit_settings($from_site_id, $to_site_id);
+ }
+
+ /**
+ * Expose verify_kit_integrity.
+ */
+ public static function verify_kit_integrity($from_site_id, $to_site_id) {
+ parent::verify_kit_integrity($from_site_id, $to_site_id);
+ }
+}
diff --git a/tests/WP_Ultimo/Helpers/Site_Duplicator_Test.php b/tests/WP_Ultimo/Helpers/Site_Duplicator_Test.php
index 6b92b057..4f57e3c2 100644
--- a/tests/WP_Ultimo/Helpers/Site_Duplicator_Test.php
+++ b/tests/WP_Ultimo/Helpers/Site_Duplicator_Test.php
@@ -296,6 +296,341 @@ public function test_duplication_with_subdirectory() {
}
}
+ /**
+ * Test backfill_nav_menu_postmeta copies missing meta keys.
+ */
+ public function test_backfill_nav_menu_postmeta_copies_missing_meta() {
+ $template_id = self::factory()->blog->create();
+ $target_id = self::factory()->blog->create();
+
+ switch_to_blog($template_id);
+ $post_id = wp_insert_post(
+ [
+ 'import_id' => 500,
+ 'post_type' => 'nav_menu_item',
+ 'post_status' => 'publish',
+ 'post_title' => 'Test Menu Item',
+ ]
+ );
+ add_post_meta($post_id, '_menu_item_type', 'custom');
+ add_post_meta($post_id, '_menu_item_url', 'https://example.com');
+ add_post_meta($post_id, '_menu_item_object', 'custom');
+ add_post_meta($post_id, '_menu_item_target', '');
+ restore_current_blog();
+
+ switch_to_blog($target_id);
+ wp_insert_post(
+ [
+ 'import_id' => 500,
+ 'post_type' => 'nav_menu_item',
+ 'post_status' => 'publish',
+ 'post_title' => 'Test Menu Item',
+ ]
+ );
+ $this->assertEmpty(get_post_meta(500, '_menu_item_type', true));
+ restore_current_blog();
+
+ $method = new \ReflectionMethod(Site_Duplicator::class, 'backfill_nav_menu_postmeta');
+ $method->setAccessible(true);
+ $method->invoke(null, $template_id, $target_id);
+
+ switch_to_blog($target_id);
+ $this->assertEquals('custom', get_post_meta(500, '_menu_item_type', true));
+ $this->assertEquals('https://example.com', get_post_meta(500, '_menu_item_url', true));
+ $this->assertEquals('custom', get_post_meta(500, '_menu_item_object', true));
+ restore_current_blog();
+
+ wpmu_delete_blog($template_id, true);
+ wpmu_delete_blog($target_id, true);
+ }
+
+ /**
+ * Test backfill_attachment_postmeta copies missing attachment meta.
+ */
+ public function test_backfill_attachment_postmeta_copies_missing_meta() {
+ $template_id = self::factory()->blog->create();
+ $target_id = self::factory()->blog->create();
+
+ switch_to_blog($template_id);
+ $post_id = wp_insert_post(
+ [
+ 'import_id' => 600,
+ 'post_type' => 'attachment',
+ 'post_status' => 'inherit',
+ 'post_title' => 'Test Image',
+ 'post_mime_type' => 'image/jpeg',
+ ]
+ );
+ add_post_meta($post_id, '_wp_attached_file', '2026/04/test-image.jpg');
+ add_post_meta($post_id, '_wp_attachment_metadata', ['width' => 800, 'height' => 600]);
+ add_post_meta($post_id, '_wp_attachment_image_alt', 'Alt text');
+ restore_current_blog();
+
+ switch_to_blog($target_id);
+ wp_insert_post(
+ [
+ 'import_id' => 600,
+ 'post_type' => 'attachment',
+ 'post_status' => 'inherit',
+ 'post_title' => 'Test Image',
+ 'post_mime_type' => 'image/jpeg',
+ ]
+ );
+ $this->assertEmpty(get_post_meta(600, '_wp_attached_file', true));
+ restore_current_blog();
+
+ $method = new \ReflectionMethod(Site_Duplicator::class, 'backfill_attachment_postmeta');
+ $method->setAccessible(true);
+ $method->invoke(null, $template_id, $target_id);
+
+ switch_to_blog($target_id);
+ $this->assertEquals('2026/04/test-image.jpg', get_post_meta(600, '_wp_attached_file', true));
+ $this->assertEquals('Alt text', get_post_meta(600, '_wp_attachment_image_alt', true));
+ $metadata = get_post_meta(600, '_wp_attachment_metadata', true);
+ $this->assertIsArray($metadata);
+ $this->assertEquals(800, $metadata['width']);
+ restore_current_blog();
+
+ wpmu_delete_blog($template_id, true);
+ wpmu_delete_blog($target_id, true);
+ }
+
+ /**
+ * Test backfill_elementor_postmeta copies _elementor_* meta for all post types.
+ */
+ public function test_backfill_elementor_postmeta_copies_missing_meta() {
+ $template_id = self::factory()->blog->create();
+ $target_id = self::factory()->blog->create();
+
+ switch_to_blog($template_id);
+ $post_id = wp_insert_post(
+ [
+ 'import_id' => 700,
+ 'post_type' => 'elementor_library',
+ 'post_status' => 'publish',
+ 'post_title' => 'Header Template',
+ ]
+ );
+ $elementor_data = '[{"id":"abc123","elType":"section","settings":{}}]';
+ add_post_meta($post_id, '_elementor_data', $elementor_data);
+ add_post_meta($post_id, '_elementor_edit_mode', 'builder');
+ add_post_meta($post_id, '_elementor_template_type', 'header');
+ restore_current_blog();
+
+ switch_to_blog($target_id);
+ wp_insert_post(
+ [
+ 'import_id' => 700,
+ 'post_type' => 'elementor_library',
+ 'post_status' => 'publish',
+ 'post_title' => 'Header Template',
+ ]
+ );
+ $this->assertEmpty(get_post_meta(700, '_elementor_data', true));
+ restore_current_blog();
+
+ $method = new \ReflectionMethod(Site_Duplicator::class, 'backfill_elementor_postmeta');
+ $method->setAccessible(true);
+ $method->invoke(null, $template_id, $target_id);
+
+ switch_to_blog($target_id);
+ $this->assertEquals($elementor_data, get_post_meta(700, '_elementor_data', true));
+ $this->assertEquals('builder', get_post_meta(700, '_elementor_edit_mode', true));
+ $this->assertEquals('header', get_post_meta(700, '_elementor_template_type', true));
+ restore_current_blog();
+
+ wpmu_delete_blog($template_id, true);
+ wpmu_delete_blog($target_id, true);
+ }
+
+ /**
+ * Test backfill_kit_settings overwrites stub data with real template values.
+ */
+ public function test_backfill_kit_settings_overwrites_stub_data() {
+ $template_id = self::factory()->blog->create();
+ $target_id = self::factory()->blog->create();
+
+ $real_settings = [
+ 'system_colors' => [
+ ['_id' => 'primary', 'color' => '#EAC7C7'],
+ ['_id' => 'secondary', 'color' => '#ED6363'],
+ ],
+ 'custom_colors' => [
+ ['_id' => 'brand', 'color' => '#FF0000'],
+ ],
+ ];
+ $real_data = '[{"id":"kit1","elType":"kit","settings":{}}]';
+
+ switch_to_blog($template_id);
+ $kit_id = wp_insert_post(
+ [
+ 'import_id' => 3,
+ 'post_type' => 'elementor_library',
+ 'post_status' => 'publish',
+ 'post_title' => 'Default Kit',
+ ]
+ );
+ update_option('elementor_active_kit', $kit_id);
+ update_post_meta($kit_id, '_elementor_page_settings', $real_settings);
+ update_post_meta($kit_id, '_elementor_data', $real_data);
+ restore_current_blog();
+
+ $stub_settings = ['system_colors' => [['_id' => 'primary', 'color' => '#6EC1E4']]];
+
+ switch_to_blog($target_id);
+ $target_kit_id = wp_insert_post(
+ [
+ 'import_id' => 3,
+ 'post_type' => 'elementor_library',
+ 'post_status' => 'publish',
+ 'post_title' => 'Default Kit',
+ ]
+ );
+ update_option('elementor_active_kit', $target_kit_id);
+ update_post_meta($target_kit_id, '_elementor_page_settings', $stub_settings);
+ restore_current_blog();
+
+ $method = new \ReflectionMethod(Site_Duplicator::class, 'backfill_kit_settings');
+ $method->setAccessible(true);
+ $method->invoke(null, $template_id, $target_id);
+
+ switch_to_blog($target_id);
+ $copied = get_post_meta(3, '_elementor_page_settings', true);
+ $this->assertIsArray($copied);
+ $this->assertEquals('#EAC7C7', $copied['system_colors'][0]['color']);
+ $this->assertEquals('#ED6363', $copied['system_colors'][1]['color']);
+ $this->assertArrayHasKey('custom_colors', $copied);
+ $this->assertEquals($real_data, get_post_meta(3, '_elementor_data', true));
+ $this->assertEmpty(get_post_meta(3, '_elementor_css', true));
+ restore_current_blog();
+
+ wpmu_delete_blog($template_id, true);
+ wpmu_delete_blog($target_id, true);
+ }
+
+ /**
+ * Test backfill_postmeta skips when source and target are the same site.
+ */
+ public function test_backfill_postmeta_skips_same_site() {
+ $site_id = self::factory()->blog->create();
+
+ switch_to_blog($site_id);
+ $post_id = wp_insert_post(
+ [
+ 'import_id' => 800,
+ 'post_type' => 'nav_menu_item',
+ 'post_status' => 'publish',
+ ]
+ );
+ add_post_meta($post_id, '_menu_item_type', 'custom');
+ restore_current_blog();
+
+ $method = new \ReflectionMethod(Site_Duplicator::class, 'backfill_postmeta');
+ $method->setAccessible(true);
+ $method->invoke(null, $site_id, $site_id);
+
+ switch_to_blog($site_id);
+ $values = get_post_meta(800, '_menu_item_type');
+ $this->assertCount(1, $values);
+ restore_current_blog();
+
+ wpmu_delete_blog($site_id, true);
+ }
+
+ /**
+ * Test backfill is idempotent — running twice does not duplicate rows.
+ */
+ public function test_backfill_nav_menu_postmeta_is_idempotent() {
+ $template_id = self::factory()->blog->create();
+ $target_id = self::factory()->blog->create();
+
+ switch_to_blog($template_id);
+ wp_insert_post(
+ [
+ 'import_id' => 900,
+ 'post_type' => 'nav_menu_item',
+ 'post_status' => 'publish',
+ ]
+ );
+ add_post_meta(900, '_menu_item_type', 'post_type');
+ add_post_meta(900, '_menu_item_url', '');
+ restore_current_blog();
+
+ switch_to_blog($target_id);
+ wp_insert_post(
+ [
+ 'import_id' => 900,
+ 'post_type' => 'nav_menu_item',
+ 'post_status' => 'publish',
+ ]
+ );
+ restore_current_blog();
+
+ $method = new \ReflectionMethod(Site_Duplicator::class, 'backfill_nav_menu_postmeta');
+ $method->setAccessible(true);
+
+ $method->invoke(null, $template_id, $target_id);
+ $method->invoke(null, $template_id, $target_id);
+
+ switch_to_blog($target_id);
+ $values = get_post_meta(900, '_menu_item_type');
+ $this->assertCount(1, $values);
+ $this->assertEquals('post_type', $values[0]);
+ restore_current_blog();
+
+ wpmu_delete_blog($template_id, true);
+ wpmu_delete_blog($target_id, true);
+ }
+
+ /**
+ * Test wu_duplicate_site action receives from_site_id.
+ */
+ public function test_wu_duplicate_site_action_includes_from_site_id() {
+ $captured = null;
+
+ add_action(
+ 'wu_duplicate_site',
+ function ($site) use (&$captured) {
+ $captured = $site;
+ }
+ );
+
+ $args = [
+ 'domain' => 'actiontest.example.com',
+ 'path' => '/',
+ 'title' => 'Action Test Site',
+ ];
+
+ $result = Site_Duplicator::duplicate_site($this->template_site_id, 'Action Test Site', $args);
+
+ if ( ! is_wp_error($result)) {
+ $this->assertIsArray($captured);
+ $this->assertArrayHasKey('from_site_id', $captured);
+ $this->assertArrayHasKey('site_id', $captured);
+ $this->assertEquals($this->template_site_id, $captured['from_site_id']);
+ $this->assertEquals($result, $captured['site_id']);
+
+ wpmu_delete_blog($result, true);
+ }
+ }
+
+ /**
+ * Test backfill_kit_settings is a no-op when template has no Kit.
+ */
+ public function test_backfill_kit_settings_noop_without_elementor() {
+ $template_id = self::factory()->blog->create();
+ $target_id = self::factory()->blog->create();
+
+ $method = new \ReflectionMethod(Site_Duplicator::class, 'backfill_kit_settings');
+ $method->setAccessible(true);
+ $method->invoke(null, $template_id, $target_id);
+
+ $this->assertTrue(true);
+
+ wpmu_delete_blog($template_id, true);
+ wpmu_delete_blog($target_id, true);
+ }
+
/**
* Clean up after tests.
*/