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. */