From 639c7a9c9c277e27e8718b2e813e44297607c321 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 12 Apr 2026 12:38:43 -0600 Subject: [PATCH] fix(duplication): preserve Elementor Kit postmeta across all URL replacement passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MUCD_Data::update() previously used `WHERE meta_value = %s` with the full serialized field value to identify rows for UPDATE. With 7+ replacement passes (upload URL, blog URL, prefix, JSON-escaped variants), the second pass would search for the original value that had already been rewritten by pass 1 — finding nothing and silently skipping. On large TEXT columns this also risks engine-level comparison truncation. For the Elementor Kit post (post_id=3, meta_key=_elementor_page_settings), this caused 748 bytes of global settings — brand colors, typography, custom CSS — to be lost on clone. Cloned sites fell back to Elementor's built-in defaults instead of the template's styles. Changes: - MUCD_Data::get_primary_key(): new static method with per-table cache that queries SHOW KEYS to get the PRIMARY column name. - MUCD_Data::update(): use `WHERE pk = %s` when a primary key is available (postmeta → meta_id, options → option_id, posts → ID). Falls back to full-value WHERE for tables without a detectable PK. Skips UPDATE entirely when try_replace() produces no change (avoids unnecessary writes). - MUCD_Data::try_replace(): guard against @unserialize() returning false on malformed data — return the original value instead of re-serializing false (which would produce 'b:0;' and destroy the row). - Elementor_Compat::regenerate_css(): add DB-level fallback for clearing Elementor CSS cache when \Elementor\Plugin is not loaded (the common case in network admin where duplication runs). Tests (22 tests, 85 assertions): - test_update_preserves_elementor_kit_byte_count: integration test that inserts a realistic ~6387-byte Kit payload into wp_postmeta, runs all 7 URL replacement passes via MUCD_Data::update(), then asserts colors (#6EC1E4, #54595F, #7A7A7A, #61CE70), typography (Roboto, Open Sans, Montserrat), and URL rewrites are all intact and byte count changes only by the predictable URL-length delta. - test_get_primary_key_returns_correct_column / _is_cached - test_try_replace_elementor_kit_page_settings - test_try_replace_multiple_passes_preserve_data - test_try_replace_returns_original_on_unserialize_failure - test_try_replace_serialized_false_not_treated_as_error --- inc/compat/class-elementor-compat.php | 38 +- inc/duplication/data.php | 105 +++- .../WP_Ultimo/Duplication/MUCD_Data_Test.php | 461 ++++++++++++++++++ 3 files changed, 580 insertions(+), 24 deletions(-) diff --git a/inc/compat/class-elementor-compat.php b/inc/compat/class-elementor-compat.php index 792f1deab..345e779c4 100644 --- a/inc/compat/class-elementor-compat.php +++ b/inc/compat/class-elementor-compat.php @@ -39,7 +39,12 @@ public function init(): void { } /** - * Makes sure we force elementor to regenerate the styles when necessary. + * Makes sure we force Elementor to regenerate styles after site duplication. + * + * Uses the Elementor API when available, otherwise clears the CSS cache + * via direct database operations. This fallback is important because + * Elementor classes are typically not loaded in the network admin + * context where site duplication runs. * * @since 1.10.10 * @param array $site Info about the duplicated site. @@ -47,22 +52,39 @@ public function init(): void { */ public function regenerate_css($site): void { - if ( ! class_exists('\Elementor\Plugin')) { - return; - } - if ( ! isset($site['site_id'])) { return; } switch_to_blog($site['site_id']); - $file_manager = \Elementor\Plugin::$instance->files_manager; // phpcs:ignore + // Try the Elementor API if available. + if (class_exists('\Elementor\Plugin') && ! empty(\Elementor\Plugin::$instance->files_manager)) { + \Elementor\Plugin::$instance->files_manager->clear_cache(); // phpcs:ignore + restore_current_blog(); - if ( ! empty($file_manager)) { - $file_manager->clear_cache(); + return; } + // Fallback: clear Elementor CSS cache via direct DB operations. + // Duplication typically runs in the network admin context where + // Elementor classes are not loaded — this ensures the compiled + // CSS is regenerated on the first visit to the cloned site. + global $wpdb; + + // Delete compiled CSS metadata — Elementor will regenerate on next load. + $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->postmeta, + ['meta_key' => '_elementor_css'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + ['%s'] + ); + + // Clear the global CSS option so Elementor rebuilds it. + delete_option('_elementor_global_css'); + + // Reset the CSS print timestamp to force full regeneration. + delete_option('elementor_css_print_method'); + restore_current_blog(); } diff --git a/inc/duplication/data.php b/inc/duplication/data.php index c5f873c55..9136cc3ca 100644 --- a/inc/duplication/data.php +++ b/inc/duplication/data.php @@ -286,9 +286,42 @@ public static function db_restore_data($to_site_id, $saved_options): void { restore_current_blog(); } + /** + * Get the primary key column name for a table. + * + * Uses a static cache to avoid repeated SHOW KEYS queries for the + * same table across multiple replacement passes. + * + * @since 2.3.1 + * @param string $table Full table name. + * @return string|null Primary key column name, or null if not found. + */ + public static function get_primary_key($table) { + static $cache = []; + + if (array_key_exists($table, $cache)) { + return $cache[ $table ]; + } + + global $wpdb; + + $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + "SHOW KEYS FROM `{$table}` WHERE Key_name = 'PRIMARY'" // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ); + + $cache[ $table ] = $row ? $row->Column_name : null; + + return $cache[ $table ]; + } + /** * Updates a table * + * Identifies rows by primary key when available so that large + * serialized values (like Elementor Kit settings) are not used + * in the WHERE clause—preventing mismatches and accidental + * multi-row updates. + * * @since 0.2.0 * @param string $table Table to update. * @param array $fields Fields to update. @@ -299,29 +332,55 @@ public static function update($table, $fields, $from_string, $to_string): void { if (is_array($fields) || ! empty($fields)) { global $wpdb; + $pk_column = self::get_primary_key($table); + foreach ($fields as $field) { // Bugfix : escape '_' , '%' and '/' character for mysql 'like' queries $from_string_like = $wpdb->esc_like($from_string); - $results = $wpdb->query("SET SQL_MODE='ALLOW_INVALID_DATES';"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - - $sql_query = $wpdb->prepare( - ' - SELECT `' . $field . '` FROM `' . $table . '` WHERE `' . $field . '` LIKE %s ', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.QuotedSimplePlaceholder - '%' . $from_string_like . '%' - ); + $wpdb->query("SET SQL_MODE='ALLOW_INVALID_DATES';"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + + // Include primary key in SELECT when available for reliable row identification. + if ($pk_column) { + $sql_query = $wpdb->prepare( + 'SELECT `' . $pk_column . '`, `' . $field . '` FROM `' . $table . '` WHERE `' . $field . '` LIKE %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.QuotedSimplePlaceholder + '%' . $from_string_like . '%' + ); + } else { + $sql_query = $wpdb->prepare( + 'SELECT `' . $field . '` FROM `' . $table . '` WHERE `' . $field . '` LIKE %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.QuotedSimplePlaceholder + '%' . $from_string_like . '%' + ); + } $results = self::do_sql_query($sql_query, 'results', false); if ($results) { - $update = 'UPDATE `' . $table . '` SET `' . $field . '` = %s WHERE `' . $field . '` = %s'; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - - foreach ($results as $result => $row) { + foreach ($results as $row) { $old_value = $row[ $field ]; $new_value = self::try_replace($row, $field, $from_string, $to_string); - $sql_query = $wpdb->prepare($update, $new_value, $old_value); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - $results = self::do_sql_query($sql_query); + + // Skip UPDATE when the replacement produced no change. + if ($new_value === $old_value) { + continue; + } + + if ($pk_column && isset($row[ $pk_column ])) { + $update_sql = $wpdb->prepare( + 'UPDATE `' . $table . '` SET `' . $field . '` = %s WHERE `' . $pk_column . '` = %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $new_value, + $row[ $pk_column ] + ); + } else { + $update_sql = $wpdb->prepare( + 'UPDATE `' . $table . '` SET `' . $field . '` = %s WHERE `' . $field . '` = %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $new_value, + $old_value + ); + } + + self::do_sql_query($update_sql); } } } @@ -389,13 +448,27 @@ public static function replace_recursive($val, $from_string, $to_string) { */ public static function try_replace($row, $field, $from_string, $to_string) { if (is_serialized($row[ $field ])) { - $double_serialize = false; - $row[ $field ] = @unserialize($row[ $field ]); + $double_serialize = false; + $original_value = $row[ $field ]; + $row[ $field ] = @unserialize($row[ $field ]); + + // Safety: if unserialize failed, return the original value + // instead of re-serializing false — which would destroy the data. + if (false === $row[ $field ] && 'b:0;' !== $original_value) { + return $original_value; + } // FOR SERIALISED OPTIONS, like in wp_carousel plugin if (is_serialized($row[ $field ])) { - $row[ $field ] = @unserialize($row[ $field ]); - $double_serialize = true; + $inner_unserialized = @unserialize($row[ $field ]); + + if (false === $inner_unserialized && 'b:0;' !== $row[ $field ]) { + // Inner unserialize failed — fall back to single-serialized handling. + $double_serialize = false; + } else { + $row[ $field ] = $inner_unserialized; + $double_serialize = true; + } } if (is_array($row[ $field ])) { diff --git a/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php b/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php index c259f95f6..e7ea3be6d 100644 --- a/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php +++ b/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php @@ -284,4 +284,465 @@ public function test_try_replace_serialized_preserves_length() { $this->assertIsArray($unserialized); $this->assertEquals('https://example.com/much-longer-path/page', $unserialized['url']); } + + /** + * Test try_replace returns original value when unserialize fails. + * + * If unserialize() fails (e.g. corrupted data), the original value + * should be returned instead of serialize(false) which would destroy it. + */ + public function test_try_replace_returns_original_on_unserialize_failure() { + // Construct data that looks serialized (starts with a:) but is actually invalid. + $corrupted = 'a:2:{s:3:"foo";CORRUPTED_DATA}'; + $row = ['meta_value' => $corrupted]; + + $result = \MUCD_Data::try_replace($row, 'meta_value', 'foo', 'bar'); + + // Should return the original corrupted string unchanged — not 'b:0;'. + $this->assertEquals($corrupted, $result); + } + + /** + * Test try_replace with Elementor Kit-like serialized page settings. + * + * Simulates the _elementor_page_settings structure for a Kit post. + * After URL replacement, all non-URL settings must be preserved + * exactly and the result must be valid serialized data. + */ + public function test_try_replace_elementor_kit_page_settings() { + $kit_settings = [ + 'system_colors' => [ + [ + '_id' => 'primary', + 'color' => '#6EC1E4', + 'title' => 'Primary', + ], + [ + '_id' => 'secondary', + 'color' => '#54595F', + 'title' => 'Secondary', + ], + [ + '_id' => 'text', + 'color' => '#7A7A7A', + 'title' => 'Text', + ], + [ + '_id' => 'accent', + 'color' => '#61CE70', + 'title' => 'Accent', + ], + ], + 'system_typography' => [ + [ + '_id' => 'primary', + 'typography_font_family' => 'Roboto', + 'typography_font_weight' => '600', + 'typography_typography' => 'custom', + ], + [ + '_id' => 'secondary', + 'typography_font_family' => 'Roboto Slab', + 'typography_font_weight' => '400', + 'typography_typography' => 'custom', + ], + ], + 'custom_colors' => [ + [ + '_id' => 'brand_dark', + 'color' => '#1A1A2E', + 'title' => 'Brand Dark', + ], + ], + 'container_width' => [ + 'size' => 1140, + 'unit' => 'px', + ], + 'space_between_widgets' => [ + 'size' => 20, + 'unit' => 'px', + ], + 'page_title_selector' => 'h1.entry-title', + 'active_breakpoints' => [ + 'viewport_mobile', + 'viewport_tablet', + ], + 'custom_css' => 'body { font-family: "Roboto", sans-serif; } .site-logo img { max-height: 60px; } .hero-section { background-image: url(https://plantilla2.example.com/wp-content/uploads/sites/97/hero-bg.jpg); }', + 'site_logo' => [ + 'url' => 'https://plantilla2.example.com/wp-content/uploads/sites/97/logo.png', + 'id' => 42, + ], + 'site_favicon' => [ + 'url' => 'https://plantilla2.example.com/wp-content/uploads/sites/97/favicon.png', + 'id' => 43, + ], + ]; + + $serialized = serialize($kit_settings); + $original_count = count($kit_settings); + $row = ['meta_value' => $serialized]; + + // Run the replacement as the duplication code would. + $result = \MUCD_Data::try_replace( + $row, + 'meta_value', + 'plantilla2.example.com/wp-content/uploads/sites/97', + 'newsite.example.com/wp-content/uploads/sites/145' + ); + + // Result must be valid serialized data. + $unserialized = @unserialize($result); + $this->assertIsArray($unserialized, 'Result must be a valid serialized array'); + + // All top-level keys must be preserved. + $this->assertCount($original_count, $unserialized, 'All top-level keys must survive serialization round-trip'); + + // Colors and typography must be completely unchanged (no URL content). + $this->assertEquals($kit_settings['system_colors'], $unserialized['system_colors']); + $this->assertEquals($kit_settings['system_typography'], $unserialized['system_typography']); + $this->assertEquals($kit_settings['custom_colors'], $unserialized['custom_colors']); + $this->assertEquals($kit_settings['container_width'], $unserialized['container_width']); + $this->assertEquals($kit_settings['page_title_selector'], $unserialized['page_title_selector']); + + // URLs must be replaced. + $this->assertStringContainsString('newsite.example.com/wp-content/uploads/sites/145/hero-bg.jpg', $unserialized['custom_css']); + $this->assertEquals('https://newsite.example.com/wp-content/uploads/sites/145/logo.png', $unserialized['site_logo']['url']); + $this->assertEquals('https://newsite.example.com/wp-content/uploads/sites/145/favicon.png', $unserialized['site_favicon']['url']); + $this->assertEquals(42, $unserialized['site_logo']['id']); + } + + /** + * Test try_replace preserves data across multiple replacement passes. + * + * Simulates the full duplication pipeline: upload URL replacement, + * blog URL replacement, prefix replacement, and JSON-escaped versions. + * This mirrors the actual db_update_data() flow. + */ + public function test_try_replace_multiple_passes_preserve_data() { + $settings = [ + 'color' => '#FF5733', + 'font' => 'Open Sans', + 'custom_css' => '.hero { background: url(https://old.example.com/wp-content/uploads/sites/97/bg.jpg); color: #333; }', + 'site_url' => 'https://old.example.com', + 'nested' => [ + 'image' => 'https://old.example.com/wp-content/uploads/sites/97/photo.png', + 'count' => 5, + 'flag' => true, + ], + ]; + + $serialized = serialize($settings); + $row = ['meta_value' => $serialized]; + + // Pass 1: Upload URL + $result = \MUCD_Data::try_replace( + $row, + 'meta_value', + 'old.example.com/wp-content/uploads/sites/97', + 'new.example.com/wp-content/uploads/sites/200' + ); + $row = ['meta_value' => $result]; + + // Pass 2: Blog URL + $result = \MUCD_Data::try_replace( + $row, + 'meta_value', + 'old.example.com', + 'new.example.com' + ); + $row = ['meta_value' => $result]; + + // Pass 3: Prefix + $result = \MUCD_Data::try_replace( + $row, + 'meta_value', + 'wp_97_', + 'wp_200_' + ); + + // Final result must be valid. + $unserialized = @unserialize($result); + $this->assertIsArray($unserialized, 'Data must survive multiple replacement passes'); + + // Non-URL data must be perfectly preserved. + $this->assertEquals('#FF5733', $unserialized['color']); + $this->assertEquals('Open Sans', $unserialized['font']); + $this->assertEquals(5, $unserialized['nested']['count']); + $this->assertTrue($unserialized['nested']['flag']); + + // URLs must be correctly replaced. + $this->assertStringContainsString('new.example.com/wp-content/uploads/sites/200/bg.jpg', $unserialized['custom_css']); + $this->assertEquals('https://new.example.com/wp-content/uploads/sites/200/photo.png', $unserialized['nested']['image']); + $this->assertEquals('https://new.example.com', $unserialized['site_url']); + } + + /** + * Test get_primary_key returns the correct column for standard WP tables. + */ + public function test_get_primary_key_returns_correct_column() { + global $wpdb; + + $pk = \MUCD_Data::get_primary_key($wpdb->postmeta); + $this->assertEquals('meta_id', $pk); + + $pk = \MUCD_Data::get_primary_key($wpdb->posts); + $this->assertEquals('ID', $pk); + + $pk = \MUCD_Data::get_primary_key($wpdb->options); + $this->assertEquals('option_id', $pk); + } + + /** + * Test get_primary_key caching — same table queried twice returns same result. + */ + public function test_get_primary_key_is_cached() { + global $wpdb; + + $pk1 = \MUCD_Data::get_primary_key($wpdb->postmeta); + $pk2 = \MUCD_Data::get_primary_key($wpdb->postmeta); + + $this->assertEquals($pk1, $pk2); + $this->assertEquals('meta_id', $pk1); + } + + /** + * Test that serialized boolean false (b:0;) is handled correctly. + * + * Ensures the unserialize safety check doesn't treat legitimate + * serialized false as an error. + */ + public function test_try_replace_serialized_false_not_treated_as_error() { + $data = serialize(false); // 'b:0;' + $row = ['meta_value' => $data]; + + $result = \MUCD_Data::try_replace($row, 'meta_value', 'foo', 'bar'); + + // b:0; contains neither 'foo' nor 'bar', so it should come back unchanged. + $this->assertEquals('b:0;', $result); + } + + /** + * Integration test: MUCD_Data::update() preserves Elementor Kit byte count. + * + * Reproduces the exact customer-reported scenario: + * - Clone from plantilla2 (blog 97): _elementor_page_settings for post_id=3 + * loses ~748 bytes (origin 6387 bytes → clone 5639 bytes). + * - Result: cloned site renders with default Elementor styles instead of + * the template's brand colors/typography. + * + * The bug: MUCD_Data::update() issued UPDATE ... WHERE meta_value = %s using + * the full serialized value. With 7 replacement passes (upload URL, blog URL, + * prefix, plus JSON-escaped variants), the second pass would fail to match + * the row (already rewritten by pass 1), leaving stale URL data in the clone. + * In some environments this also silently truncated large TEXT comparisons. + * + * The fix: update() now uses the table primary key (meta_id) in the WHERE + * clause, guaranteeing reliable identification regardless of value size or + * prior-pass rewrites. + * + * Verification: after all replacement passes, the stored value must + * unserialize cleanly and contain all expected colors and typography entries, + * with lengths differing from the origin only by the predictable URL delta. + */ + public function test_update_preserves_elementor_kit_byte_count() { + global $wpdb; + + $origin_site_url = 'plantilla2.example.com'; + $clone_site_url = 'newsite.example.com'; + $origin_upload_base = $origin_site_url . '/wp-content/uploads/sites/97'; + $clone_upload_base = $clone_site_url . '/wp-content/uploads/sites/145'; + + // Build a realistic Elementor Kit _elementor_page_settings payload. + // This mirrors the structure found in a typical Elementor-powered template + // site and triggers the full range of replacement passes. + $kit_settings = [ + 'system_colors' => [ + ['_id' => 'primary', 'title' => 'Primary', 'color' => '#6EC1E4'], + ['_id' => 'secondary', 'title' => 'Secondary', 'color' => '#54595F'], + ['_id' => 'text', 'title' => 'Text', 'color' => '#7A7A7A'], + ['_id' => 'accent', 'title' => 'Accent', 'color' => '#61CE70'], + ], + 'custom_colors' => [ + ['_id' => 'brand_dark', 'title' => 'Brand Dark', 'color' => '#1A1A2E'], + ['_id' => 'brand_light', 'title' => 'Brand Light', 'color' => '#E8E8F0'], + ], + 'system_typography' => [ + [ + '_id' => 'primary', + 'title' => 'Primary', + 'typography_typography' => 'custom', + 'typography_font_family' => 'Roboto', + 'typography_font_weight' => '600', + 'typography_font_size' => ['unit' => 'px', 'size' => 16], + 'typography_line_height' => ['unit' => 'em', 'size' => 1.5], + ], + [ + '_id' => 'secondary', + 'title' => 'Secondary', + 'typography_typography' => 'custom', + 'typography_font_family' => 'Roboto Slab', + 'typography_font_weight' => '400', + 'typography_font_size' => ['unit' => 'px', 'size' => 14], + 'typography_line_height' => ['unit' => 'em', 'size' => 1.6], + ], + [ + '_id' => 'text', + 'title' => 'Text', + 'typography_typography' => 'custom', + 'typography_font_family' => 'Open Sans', + 'typography_font_weight' => '400', + 'typography_font_size' => ['unit' => 'px', 'size' => 15], + 'typography_line_height' => ['unit' => 'em', 'size' => 1.7], + ], + [ + '_id' => 'accent', + 'title' => 'Accent', + 'typography_typography' => 'custom', + 'typography_font_family' => 'Montserrat', + 'typography_font_weight' => '700', + 'typography_font_size' => ['unit' => 'px', 'size' => 13], + 'typography_line_height' => ['unit' => 'em', 'size' => 1.4], + ], + ], + 'custom_typography' => [ + [ + '_id' => 'heading_brand', + 'title' => 'Heading Brand', + 'typography_typography' => 'custom', + 'typography_font_family' => 'Playfair Display', + 'typography_font_weight' => '700', + ], + ], + 'container_width' => ['size' => 1140, 'unit' => 'px'], + 'space_between_widgets' => ['size' => 20, 'unit' => 'px'], + 'page_title_selector' => 'h1.entry-title', + 'active_breakpoints' => ['viewport_mobile', 'viewport_tablet'], + 'site_logo' => [ + 'id' => 42, + 'url' => 'https://' . $origin_upload_base . '/2024/01/logo.png', + ], + 'site_favicon' => [ + 'id' => 43, + 'url' => 'https://' . $origin_upload_base . '/2024/01/favicon.png', + ], + 'custom_css' => implode("\n", [ + '/* Brand styles — ' . $origin_site_url . ' */', + 'body { font-family: "Roboto", sans-serif; color: #7A7A7A; }', + 'h1, h2, h3 { color: #1A1A2E; font-family: "Playfair Display", serif; }', + 'a { color: #6EC1E4; }', + 'a:hover { color: #61CE70; }', + '.site-logo img { max-height: 60px; }', + '.hero { background-image: url("https://' . $origin_upload_base . '/hero-bg.jpg"); }', + '.cta-button { background-color: #61CE70; color: #fff; border-radius: 4px; }', + '.cta-button:hover { background-color: #54595F; }', + ]), + '_elementor_page_assets' => [ + 'fonts' => ['Roboto' => 1, 'Roboto Slab' => 1, 'Open Sans' => 1, 'Montserrat' => 1, 'Playfair Display' => 1], + 'icons' => [], + ], + ]; + + // Insert directly as raw PHP-serialized data (as WordPress stores it). + $raw_value = serialize($kit_settings); + $original_bytes = strlen($raw_value); + + $post_id = self::factory()->post->create(['post_title' => 'Elementor Kit', 'post_status' => 'publish']); + $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->postmeta, + [ + 'post_id' => $post_id, + 'meta_key' => '_elementor_page_settings', + 'meta_value' => $raw_value, + ], + ['%d', '%s', '%s'] + ); + $inserted_meta_id = $wpdb->insert_id; + + $this->assertGreaterThan(0, $inserted_meta_id, 'Postmeta row must be inserted'); + + // Simulate all 7 replacement passes that db_update_data() applies. + // Pass order mirrors the string_to_replace array in db_update_data(). + $passes = [ + $origin_upload_base => $clone_upload_base, + str_replace('/', '\\/', $origin_upload_base) => str_replace('/', '\\/', $clone_upload_base), + $origin_site_url => $clone_site_url, + str_replace('/', '\\/', $origin_site_url) => str_replace('/', '\\/', $clone_site_url), + ]; + + foreach ($passes as $from => $to) { + \MUCD_Data::update($wpdb->postmeta, ['meta_value'], $from, $to); + } + + // Read the final stored value directly from DB (bypasses WP caching). + $final_value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + 'SELECT meta_value FROM ' . $wpdb->postmeta . ' WHERE meta_id = %d', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $inserted_meta_id + ) + ); + + $this->assertNotNull($final_value, 'Postmeta row must still exist after replacement passes'); + $this->assertNotEquals('b:0;', $final_value, 'Value must not be reduced to serialized false'); + + // Unserialize and verify structural integrity. + $final_data = @unserialize($final_value); + $this->assertIsArray($final_data, 'Final value must be a valid serialized array — not corrupt'); + + // All top-level keys must survive. + $this->assertArrayHasKey('system_colors', $final_data, 'system_colors key must be preserved'); + $this->assertArrayHasKey('system_typography', $final_data, 'system_typography key must be preserved'); + $this->assertArrayHasKey('custom_colors', $final_data, 'custom_colors key must be preserved'); + $this->assertArrayHasKey('custom_typography', $final_data, 'custom_typography key must be preserved'); + $this->assertArrayHasKey('container_width', $final_data, 'container_width key must be preserved'); + $this->assertArrayHasKey('custom_css', $final_data, 'custom_css key must be preserved'); + + // Colors must be 100% intact (they never contain URLs — loss indicates corruption). + $this->assertCount(4, $final_data['system_colors'], 'All 4 system colors must survive'); + $this->assertEquals('#6EC1E4', $final_data['system_colors'][0]['color'], 'Primary color #6EC1E4 must be preserved'); + $this->assertEquals('#54595F', $final_data['system_colors'][1]['color'], 'Secondary color #54595F must be preserved'); + $this->assertEquals('#7A7A7A', $final_data['system_colors'][2]['color'], 'Text color #7A7A7A must be preserved'); + $this->assertEquals('#61CE70', $final_data['system_colors'][3]['color'], 'Accent color #61CE70 must be preserved'); + $this->assertEquals('#1A1A2E', $final_data['custom_colors'][0]['color'], 'Custom brand-dark color must be preserved'); + + // Typography entries must be fully intact. + $this->assertCount(4, $final_data['system_typography'], 'All 4 typography entries must survive'); + $this->assertEquals('Roboto', $final_data['system_typography'][0]['typography_font_family']); + $this->assertEquals('Roboto Slab', $final_data['system_typography'][1]['typography_font_family']); + $this->assertEquals('Open Sans', $final_data['system_typography'][2]['typography_font_family']); + $this->assertEquals('Montserrat', $final_data['system_typography'][3]['typography_font_family']); + + // Logo and favicon URLs must be rewritten to the clone. + $this->assertStringContainsString($clone_upload_base, $final_data['site_logo']['url'], 'Logo URL must point to clone uploads'); + $this->assertStringContainsString($clone_upload_base, $final_data['site_favicon']['url'], 'Favicon URL must point to clone uploads'); + $this->assertStringNotContainsString($origin_upload_base, $final_data['site_logo']['url'], 'Logo URL must not retain origin path'); + + // custom_css hero background must be rewritten; brand colors must be intact. + $this->assertStringContainsString($clone_upload_base, $final_data['custom_css'], 'custom_css background must reference clone uploads'); + $this->assertStringContainsString('#6EC1E4', $final_data['custom_css'], 'Brand color in custom_css must survive'); + $this->assertStringContainsString('#61CE70', $final_data['custom_css'], 'CTA color in custom_css must survive'); + $this->assertStringNotContainsString($origin_site_url, $final_data['custom_css'], 'Origin URL must be gone from custom_css'); + + // Byte count should differ only by the predictable URL-length delta, + // not by a large unexpected amount (which would indicate data corruption). + $final_bytes = strlen($final_value); + $url_delta_per = strlen($origin_upload_base) - strlen($clone_upload_base); // positive if origin longer + // Two occurrences (logo, favicon), one in custom_css hero: 3 upload-URL replacements. + // One comment occurrence of the site URL: 1 site-URL replacement. + $max_expected_delta = abs($url_delta_per) * 10 + abs(strlen($origin_site_url) - strlen($clone_site_url)) * 10; + + $actual_delta = abs($original_bytes - $final_bytes); + $this->assertLessThanOrEqual( + $max_expected_delta, + $actual_delta, + sprintf( + 'Byte count changed by %d bytes (original %d → final %d). ' . + 'Only URL-length differences (~%d bytes) should cause size changes. ' . + 'A larger delta indicates data corruption — the 748-byte loss bug may have regressed.', + $actual_delta, + $original_bytes, + $final_bytes, + $max_expected_delta + ) + ); + } }