query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); $wpdb->query( 'CREATE TABLE `' . self::POSTS . '` ( ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, post_type varchar(20) NOT NULL DEFAULT \'post\', PRIMARY KEY (ID), KEY post_type (post_type) ) DEFAULT CHARACTER SET utf8mb4' ); $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); $wpdb->query( 'CREATE TABLE `' . self::POSTMETA . '` ( meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, post_id bigint(20) unsigned NOT NULL DEFAULT 0, meta_key varchar(255) DEFAULT NULL, meta_value longtext, PRIMARY KEY (meta_id), KEY post_id (post_id), KEY meta_key (meta_key(191)) ) DEFAULT CHARACTER SET utf8mb4' ); $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); $wpdb->query( 'CREATE TABLE `' . self::FLAT . '` ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, post_id bigint(20) unsigned NOT NULL, _wp_attached_file varchar(255) DEFAULT NULL, _wp_attachment_metadata longtext DEFAULT NULL, _wp_attachment_image_alt longtext DEFAULT NULL, _wp_attachment_caption longtext DEFAULT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_post_id (post_id) ) DEFAULT CHARACTER SET utf8mb4' ); // migration_status table needed by Entity_Migration_Engine // (schema mirrors MemberBackfillIntegrationTest fixture). $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_migration_status`' ); $wpdb->query( 'CREATE TABLE `wp_itest_wpdo_migration_status` ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, entity_type varchar(50) NOT NULL, group_name varchar(50) NOT NULL, last_id bigint(20) unsigned NOT NULL DEFAULT 0, total_migrated bigint(20) unsigned NOT NULL DEFAULT 0, status varchar(20) NOT NULL DEFAULT \'pending\', started_at datetime DEFAULT NULL, completed_at datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) DEFAULT CHARACTER SET utf8mb4' ); WPDO_Entity_Registry::init(); WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); WPDO_Post_Fields::register_entity_fields(); } public static function tearDownAfterClass(): void { global $wpdb; $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_migration_status`' ); WPDO_Entity_Registry::init(); } protected function setUp(): void { global $wpdb; $wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' ); $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' ); $wpdb->query( 'TRUNCATE TABLE `' . self::FLAT . '`' ); $wpdb->query( 'TRUNCATE TABLE `wp_itest_wpdo_migration_status`' ); WPDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'attachment' ); } private function seed_attachment( int $post_id, array $metadata, string $alt = '' ): void { global $wpdb; $wpdb->insert( self::POSTS, array( 'ID' => $post_id, 'post_type' => 'attachment' ) ); // Real WP serializes _wp_attachment_metadata via PHP serialize(). $wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => '_wp_attachment_metadata', 'meta_value' => serialize( $metadata ), ) ); if ( '' !== $alt ) { $wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => '_wp_attachment_image_alt', 'meta_value' => $alt, ) ); } } // ── backfill_group_json() ──────────────────────────────────────────────── public function test_unserializes_attachment_metadata_to_json(): void { $this->seed_attachment( 1, array( 'width' => 800, 'height' => 600, 'file' => '2026/04/test.jpg', 'sizes' => array( 'thumbnail' => array( 'width' => 150, 'height' => 150 ), ), ), 'Stress test alt' ); $result = WPDO_Post_Migration::backfill_group_json( 'attachment' ); $this->assertGreaterThan( 0, $result['migrated'] ?? 0 ); $this->assertSame( 0, $result['errors'] ?? -1 ); global $wpdb; $row = $wpdb->get_row( 'SELECT _wp_attachment_metadata, _wp_attachment_image_alt FROM `' . self::FLAT . '` WHERE post_id = 1', ARRAY_A ); $this->assertNotNull( $row ); // metadata value should now be JSON. $decoded = json_decode( (string) $row['_wp_attachment_metadata'], true ); $this->assertIsArray( $decoded ); $this->assertSame( 800, $decoded['width'] ); $this->assertSame( 'thumbnail', array_keys( $decoded['sizes'] )[0] ); // Non-json field still passes through. $this->assertSame( 'Stress test alt', $row['_wp_attachment_image_alt'] ); } public function test_idempotent_re_run(): void { $this->seed_attachment( 1, array( 'width' => 100 ) ); WPDO_Post_Migration::backfill_group_json( 'attachment' ); // reset checkpoint so the engine reprocesses the same row. WPDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'attachment' ); $result2 = WPDO_Post_Migration::backfill_group_json( 'attachment' ); $this->assertSame( 0, $result2['errors'] ?? -1 ); global $wpdb; $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' ); $this->assertSame( 1, $count, 'No duplicate row after re-run.' ); } public function test_handles_already_serialized_string_safely(): void { // Attacker-style: write an object signature into _wp_attachment_metadata // (this is what safe_unserialize defends against). global $wpdb; $wpdb->insert( self::POSTS, array( 'ID' => 99, 'post_type' => 'attachment' ) ); $wpdb->insert( self::POSTMETA, array( 'post_id' => 99, 'meta_key' => '_wp_attachment_metadata', 'meta_value' => 'O:8:"stdClass":0:{}', // Object string — should be NULL'd ) ); $result = WPDO_Post_Migration::backfill_group_json( 'attachment' ); // Engine should NOT throw — safe_unserialize converts object to NULL. $this->assertSame( 0, $result['errors'] ?? -1 ); } public function test_handles_empty_postmeta_gracefully(): void { $result = WPDO_Post_Migration::backfill_group_json( 'attachment' ); $this->assertSame( 0, $result['migrated'] ?? -1 ); $this->assertSame( 0, $result['errors'] ?? -1 ); } public function test_returns_error_for_unknown_group(): void { $result = WPDO_Post_Migration::backfill_group_json( 'bogus_group' ); // Engine returns error_result with 'error' key set. $this->assertNotEmpty( $result['error'] ?? null ); } }