query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id), KEY meta_key (meta_key(191)) ) ENGINE=InnoDB" ); $wpdb->query( 'CREATE TABLE `' . self::PROFILE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, nickname VARCHAR(255) DEFAULT NULL, level BIGINT(20) DEFAULT NULL, tags 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_entity (user_id) ) ENGINE=InnoDB' ); $wpdb->query( 'CREATE TABLE `' . self::EXTRA . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, nickname VARCHAR(255) 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_entity (user_id) ) ENGINE=InnoDB' ); $wpdb->query( 'INSERT INTO `' . self::PROFILE . "` (user_id, nickname, level, tags) VALUES (1, 'old', 3, '[\"a\",\"b\"]'), (2, NULL, 5, NULL)" ); $wpdb->insert( self::EXTRA, array( 'user_id' => 1, 'nickname' => 'live' ) ); $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'level', 'meta_value' => '1' ) ); $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 2, 'meta_key' => 'tags', 'meta_value' => 'stale' ) ); // Historical rows: older than any run, so the catch-up pass leaves them alone. $wpdb->query( 'UPDATE `' . self::PROFILE . "` SET updated_at = '2020-01-01 00:00:00'" ); $wpdb->query( 'UPDATE `' . self::EXTRA . "` SET updated_at = '2020-01-01 00:00:00'" ); $GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'user' => 'aeav_only' ) ); $GLOBALS['_wp_cache'] = array(); $GLOBALS['_wp_transients'] = array(); TMDO_Mode_Manager::reset_cache(); WPDO_Entity_Registry::init(); WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); WPDO_Entity_Registry::register_group( 'user', 'rn_profile', array( array( 'key' => 'nickname', 'type' => 'text' ), array( 'key' => 'level', 'type' => 'integer' ), array( 'key' => 'tags', 'type' => 'json' ), ) ); WPDO_Entity_Registry::register_group( 'user', 'rn_extra', array( array( 'key' => 'nickname', 'type' => 'text' ) ) ); $ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' ); $ref->setAccessible( true ); $ref->setValue( null, array() ); } protected function tearDown(): void { self::drop(); WPDO_Entity_Registry::init(); TMDO_Mode_Manager::reset_cache(); } private static function drop(): void { global $wpdb; foreach ( array( $wpdb->usermeta, self::PROFILE, self::EXTRA ) as $table ) { $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); } } /** @return string[] meta_value rows for (user, key). */ private function native( int $user_id, string $key ): array { global $wpdb; return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM `{$wpdb->usermeta}` WHERE user_id = %d AND meta_key = %s", $user_id, $key ) ); } public function test_dry_run_counts_without_writing(): void { $report = TMDO_Native_Restorer::run( 'user', true ); $this->assertSame( 2, $report['groups']['rn_profile']['rows'] ); $this->assertSame( 1, $report['groups']['rn_extra']['rows'] ); $this->assertSame( array( '1' ), $this->native( 1, 'level' ) ); $this->assertFalse( $report['demoted'] ); } public function test_native_meta_ends_up_matching_current_reads(): void { TMDO_Native_Restorer::run( 'user', false, 1 ); $this->assertSame( array( 'live' ), $this->native( 1, 'nickname' ), 'the group reads use (rn_extra) wins' ); $this->assertSame( array( '3' ), $this->native( 1, 'level' ), 'stale native value replaced' ); $this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ), 'json stored the way WP stores arrays' ); $this->assertSame( array( '5' ), $this->native( 2, 'level' ) ); $this->assertSame( array(), $this->native( 2, 'tags' ), 'NULL flat value removes the stale native row' ); $this->assertSame( array(), $this->native( 2, 'nickname' ) ); } public function test_completion_clears_progress_and_demote_leaves_aeav_only(): void { $report = TMDO_Native_Restorer::run( 'user', false, 500, true ); $this->assertTrue( $report['demoted'] ); $this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'user' ) ); $this->assertFalse( get_option( TMDO_Native_Restorer::CURSOR_OPTION ) ); $this->assertFalse( get_option( TMDO_Native_Restorer::STARTED_OPTION ) ); } public function test_resumes_after_the_stored_cursor(): void { update_option( TMDO_Native_Restorer::CURSOR_OPTION, array( 'user/rn_profile' => 1 ) ); TMDO_Native_Restorer::run( 'user', false ); $this->assertSame( array( '1' ), $this->native( 1, 'level' ), 'user 1 was done before the interruption' ); $this->assertSame( array( '5' ), $this->native( 2, 'level' ) ); } public function test_catch_up_copies_rows_written_during_the_run(): void { global $wpdb; $wpdb->query( 'UPDATE `' . self::PROFILE . "` SET level = 9, updated_at = NOW() + INTERVAL 1 HOUR WHERE user_id = 1" ); update_option( TMDO_Native_Restorer::CURSOR_OPTION, array( 'user/rn_profile' => 1 ) ); TMDO_Native_Restorer::run( 'user', false ); $this->assertSame( array( '9' ), $this->native( 1, 'level' ) ); } private function set_user_mode( string $mode ): void { $GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => $mode ); TMDO_Mode_Manager::reset_cache(); } /** * Native is the source of truth outside aeav_only (e.g. WooCommerce writes * stock with direct SQL): the default policy there only fills gaps. */ public function test_native_authoritative_modes_only_fill_missing_rows(): void { $this->set_user_mode( 'dual_write' ); $report = TMDO_Native_Restorer::run( 'user', false ); $this->assertSame( array( '1' ), $this->native( 1, 'level' ), 'differing native value kept' ); $this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ), 'missing native row filled' ); $this->assertSame( array( '5' ), $this->native( 2, 'level' ) ); $this->assertSame( array( 'stale' ), $this->native( 2, 'tags' ), 'NULL flat value does not delete native here' ); $this->assertSame( 'only_missing', $report['policy'] ); $this->assertSame( 2, $report['groups']['rn_profile']['missing'] ); $this->assertSame( 2, $report['groups']['rn_profile']['differs'] ); } public function test_overwrite_policy_replaces_native_in_any_mode(): void { $this->set_user_mode( 'dual_write' ); TMDO_Native_Restorer::run( 'user', false, 500, false, '', 'overwrite' ); $this->assertSame( array( '3' ), $this->native( 1, 'level' ) ); $this->assertSame( array(), $this->native( 2, 'tags' ) ); } public function test_only_missing_policy_keeps_native_in_aeav_only(): void { TMDO_Native_Restorer::run( 'user', false, 500, false, '', 'only_missing' ); $this->assertSame( array( '1' ), $this->native( 1, 'level' ) ); $this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ) ); } public function test_dry_run_classifies_without_writing(): void { $report = TMDO_Native_Restorer::run( 'user', true ); $this->assertSame( 'overwrite', $report['policy'], 'aeav_only: flat is the source of truth' ); $this->assertSame( 2, $report['groups']['rn_profile']['missing'] ); $this->assertSame( 2, $report['groups']['rn_profile']['differs'] ); $this->assertSame( 0, $report['groups']['rn_profile']['same'] ); $this->assertSame( array( '1' ), $this->native( 1, 'level' ) ); } /** * A flat column holding its field default for an entity that never had * the key (e.g. activity.session_count = 0) is not written back. */ public function test_defaults_are_not_invented_as_native_rows(): void { global $wpdb; $table = 'wp_itest_wpdo_user_rn_counts'; $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); $wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, visits BIGINT(20) DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB" ); $wpdb->query( "INSERT INTO `{$table}` (user_id, visits, updated_at) VALUES (1, 0, '2020-01-01'), (2, 7, '2020-01-01')" ); WPDO_Entity_Registry::register_group( 'user', 'rn_counts', array( array( 'key' => 'visits', 'type' => 'integer', 'default' => 0 ) ) ); WPDO_Schema_Manager::flush_table_exists_cache(); try { $report = TMDO_Native_Restorer::run( 'user', false, 500, false, 'rn_counts' ); $this->assertSame( array(), $this->native( 1, 'visits' ), 'default 0 not invented' ); $this->assertSame( array( '7' ), $this->native( 2, 'visits' ) ); $this->assertSame( 1, $report['groups']['rn_counts']['default_skipped'] ); } finally { $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); } } }