8c049f0cf2
- TMDO_Native_Restorer::run() gains a policy: overwrite (flat wins; the default in aeav_only, where writes only reached flat and native is stale) or only_missing (native wins, flat only fills gaps; the default in every other mode, where native is the source of truth and may be newer — on dev36 an aeav_only round trip overwrote WooCommerce stock 42 with 0). CLI: --overwrite / --only-missing. - Native values are read first and each owned key is classified as missing / differs / same / default_skipped; unchanged values are no longer rewritten. A flat value equal to its field default is not written for an entity without a native row (dev36: 1,368 invented session_count / account_flags rows). --dry-run reports the counts. - Verify: NativeRestorer* 17 OK (5 new: policy by mode, forced overwrite, only-missing in aeav_only, dry-run counts, no invented defaults); unit 687 OK, integration 490 OK, phpcs (phpcs.xml) 0, stan OK; dev36 `bridge-restore-native user --dry-run`: policy only_missing, activity 120 defaults skipped - PR: PR-058
216 lines
10 KiB
PHP
216 lines
10 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Writing flat-table values back to native meta so an aeav_only entity can
|
|
* leave aeav_only.
|
|
*
|
|
* Rule: after the write-back, native meta returns what reads return today.
|
|
* Fixture (user entity, both groups hold `nickname`; the legacy owner is
|
|
* rn_extra, registered last):
|
|
* rn_profile user 1: nickname 'old', level 3, tags ["a","b"]
|
|
* user 2: nickname NULL, level 5, tags NULL
|
|
* rn_extra user 1: nickname 'live'
|
|
* usermeta user 1 level '1' (stale), user 2 tags 'stale'
|
|
*/
|
|
class NativeRestorerTest extends TestCase {
|
|
|
|
private const PROFILE = 'wp_itest_wpdo_user_rn_profile';
|
|
private const EXTRA = 'wp_itest_wpdo_user_rn_extra';
|
|
|
|
public static function setUpBeforeClass(): void {
|
|
$base = WPDO_PLUGIN_DIR;
|
|
foreach ( array(
|
|
'includes/adapters/interface-entity-adapter.php',
|
|
'includes/engine/class-tmdo-type-caster.php',
|
|
'includes/engine/class-tmdo-schema-manager.php',
|
|
'includes/engine/class-tmdo-entity-registry.php',
|
|
'includes/engine/class-tmdo-mode-manager.php',
|
|
'includes/engine/class-tmdo-cache-orchestrator.php',
|
|
'includes/adapters/class-tmdo-adapter-user.php',
|
|
'includes/migration/class-tmdo-native-restorer.php',
|
|
) as $file ) {
|
|
require_once $base . $file;
|
|
}
|
|
}
|
|
|
|
protected function setUp(): void {
|
|
global $wpdb;
|
|
self::drop();
|
|
$wpdb->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}`" );
|
|
}
|
|
}
|
|
}
|