7807eae957
- Admin "backfill all" and `wp tmdo post-migrate-group` take any registered post group via TMDO_Post_Migration::backfillable_groups() instead of a hard-coded list of 7 (dev36 registers 11; the 4 HP add-on groups were never backfilled). Restored orphan groups are excluded and rejected — their values live only in the flat table. - Groups with json fields (attachment, nav_menu_item) go row by row through the migration engine instead of leaving the json columns NULL in the bulk pivot. - Verify: PostBackfillJsonTest (3 RED on hardening/2.2); dev36 post-migrate-group attachment / nav_menu_item / hp_draft_flags: json columns semantically equal native (24/24, 21/21), checker unchanged. - PR: PR-067
257 lines
9.9 KiB
PHP
257 lines
9.9 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Integration test: WPDO_Post_Migration::backfill_group_json() (v2.10.4).
|
|
*
|
|
* Validates row-by-row backfill for groups containing json-typed fields
|
|
* (attachment._wp_attachment_metadata, nav_menu_item._menu_item_classes,
|
|
* etc.) — these can't go through the bulk SQL pivot in backfill_group()
|
|
* because the values need PHP-level safe_unserialize → json_encode.
|
|
*
|
|
* Method is a thin wrapper over WPDO_Entity_Migration_Engine::migrate_group()
|
|
* which is already entity-agnostic; this test confirms the dispatch and
|
|
* sanity-checks output for post entity.
|
|
*/
|
|
class PostBackfillJsonTest extends TestCase {
|
|
|
|
private const POSTS = 'wp_itest_posts';
|
|
private const POSTMETA = 'wp_itest_postmeta';
|
|
private const FLAT = 'wp_itest_wpdo_post_attachment';
|
|
|
|
public static function setUpBeforeClass(): void {
|
|
global $wpdb;
|
|
|
|
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface' ) ) {
|
|
require_once WPDO_PLUGIN_DIR . 'includes/adapters/interface-entity-adapter.php';
|
|
}
|
|
foreach ( array(
|
|
'includes/engine/class-tmdo-entity-registry.php',
|
|
'includes/engine/class-tmdo-mode-manager.php',
|
|
'includes/engine/class-tmdo-schema-manager.php',
|
|
'includes/engine/class-tmdo-type-caster.php',
|
|
'includes/engine/class-tmdo-entity-migration-engine.php',
|
|
'includes/adapters/class-tmdo-adapter-post.php',
|
|
'includes/integrations/class-tmdo-post-fields.php',
|
|
'includes/migration/class-tmdo-post-migration.php',
|
|
) as $rel ) {
|
|
$file = WPDO_PLUGIN_DIR . $rel;
|
|
if ( file_exists( $file ) ) {
|
|
require_once $file;
|
|
}
|
|
}
|
|
|
|
$wpdb->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 );
|
|
}
|
|
|
|
// ── backfill_group() / backfillable_groups() (2.2 PR-067) ───────────────
|
|
|
|
public function test_backfill_group_fills_json_columns_instead_of_skipping_them(): void {
|
|
$this->seed_attachment( 1, array( 'width' => 640 ), 'Alt text' );
|
|
|
|
$result = WPDO_Post_Migration::backfill_group( 'attachment' );
|
|
|
|
$this->assertSame( 'attachment', $result['group'] );
|
|
global $wpdb;
|
|
$row = $wpdb->get_row( 'SELECT _wp_attachment_metadata, _wp_attachment_image_alt FROM `' . self::FLAT . '` WHERE post_id = 1', ARRAY_A );
|
|
$this->assertSame( array( 'width' => 640 ), json_decode( (string) $row['_wp_attachment_metadata'], true ), 'json field must not be left NULL by the bulk pivot' );
|
|
$this->assertSame( 'Alt text', $row['_wp_attachment_image_alt'] );
|
|
}
|
|
|
|
public function test_backfillable_groups_are_the_registered_post_groups(): void {
|
|
WPDO_Entity_Registry::register_group( 'post', 'itest_extra', array( array( 'key' => 'itest_extra_key', 'type' => 'text' ) ) );
|
|
|
|
$groups = WPDO_Post_Migration::backfillable_groups();
|
|
|
|
$this->assertSame( WPDO_Entity_Registry::get_groups_for_type( 'post' ), $groups );
|
|
$this->assertContains( 'itest_extra', $groups, 'a group registered by an add-on must be offered for backfill' );
|
|
}
|
|
|
|
public function test_restored_orphan_groups_are_never_backfilled(): void {
|
|
WPDO_Entity_Registry::restore_group( 'post', 'itest_orphan', array( array( 'key' => 'itest_orphan_key', 'type' => 'text' ) ) );
|
|
$this->assertContains( 'itest_orphan', WPDO_Entity_Registry::get_groups_for_type( 'post' ) );
|
|
|
|
$this->assertNotContains( 'itest_orphan', WPDO_Post_Migration::backfillable_groups(), 'orphan data lives only in the flat table' );
|
|
$this->expectException( InvalidArgumentException::class );
|
|
WPDO_Post_Migration::backfill_group( 'itest_orphan' );
|
|
}
|
|
|
|
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 );
|
|
}
|
|
}
|