query( "CREATE TABLE `{$wpdb->users}` ( ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_login VARCHAR(60) NOT NULL DEFAULT '', PRIMARY KEY (ID) ) ENGINE=InnoDB" ); $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) ) ENGINE=InnoDB" ); $wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, num DECIMAL(18,6) DEFAULT NULL, cnt BIGINT(20) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, prefs TEXT DEFAULT NULL, meta_json LONGTEXT DEFAULT NULL, flag BIGINT(20) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' ); for ( $id = 1; $id <= 7; $id++ ) { $wpdb->insert( $wpdb->users, array( 'ID' => $id, 'user_login' => "u{$id}" ) ); } $prefs = serialize( array( 'core' => array( 'welcomeGuide' => false ) ) ); $this->native( 1, array( 'num' => '50', 'cnt' => '3', 'name' => 'a', 'prefs' => $prefs, 'meta_json' => serialize( array( 'w' => 640, 'tags' => array( 'x' ) ) ) ) ); $this->flat( array( 'user_id' => 1, 'num' => '50.000000', 'cnt' => 3, 'name' => 'a', 'prefs' => $prefs, 'meta_json' => '{"w":640,"tags":["x"]}' ) ); $this->native( 2, array( 'name' => 'b', 'cnt' => '5' ) ); $this->flat( array( 'user_id' => 2, 'name' => 'B', 'cnt' => 6 ) ); $this->native( 3, array( 'name' => 'c' ) ); $this->flat( array( 'user_id' => 3, 'cnt' => null ) ); $this->flat( array( 'user_id' => 4, 'cnt' => 9 ) ); $this->native( 5, array( 'name' => '' ) ); $this->flat( array( 'user_id' => 5 ) ); $this->native( 6, array( 'name' => str_repeat( 'x', 300 ) ) ); $this->flat( array( 'user_id' => 6 ) ); $this->native( 7, array( 'cnt' => '7' ) ); $GLOBALS['_wp_options'] = array(); $GLOBALS['_wp_cache'] = array(); WPDO_Entity_Registry::init(); WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); WPDO_Entity_Registry::register_group( 'user', 'dc_grp', array( array( 'key' => 'num', 'type' => 'decimal' ), array( 'key' => 'cnt', 'type' => 'integer' ), array( 'key' => 'name', 'type' => 'text' ), array( 'key' => 'prefs', 'type' => 'textarea' ), array( 'key' => 'meta_json', 'type' => 'json' ), array( 'key' => 'flag', 'type' => 'integer', 'default' => 0, 'required' => true ), ) ); WPDO_Schema_Manager::flush_table_exists_cache(); } protected function tearDown(): void { self::drop(); WPDO_Entity_Registry::init(); } private static function drop(): void { global $wpdb; foreach ( array( $wpdb->users, $wpdb->usermeta, self::TABLE ) as $table ) { $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); } } private function native( int $user_id, array $values ): void { global $wpdb; foreach ( $values as $key => $value ) { $wpdb->insert( $wpdb->usermeta, array( 'user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value ) ); } } private function flat( array $row ): void { global $wpdb; $wpdb->insert( self::TABLE, $row ); } private function snapshot(): string { global $wpdb; return md5( wp_json_encode( array( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ), $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) ); } public function test_counts_miss_diff_and_extra_per_key(): void { $report = TMDO_Drift_Check::run( 'user' ); $this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] ); $keys = $report['groups']['dc_grp']['keys']; $this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 0 ), $keys['name'] ); $this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 1 ), $keys['cnt'] ); foreach ( array( 'num', 'prefs', 'meta_json', 'flag' ) as $key ) { $this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $keys[ $key ], "{$key}: equal by value" ); } } public function test_records_the_outcome_per_group(): void { TMDO_Drift_Check::run( 'user', 'dc_grp' ); $status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']; $this->assertSame( 2, $status['miss'] ); $this->assertSame( 2, $status['diff'] ); $this->assertSame( 1, $status['extra'] ); $this->assertSame( 0, $status['sample'] ); $this->assertNotEmpty( $status['checked_at'] ); } public function test_a_sample_covering_every_user_matches_the_full_check(): void { $report = TMDO_Drift_Check::run( 'user', '', 100 ); $this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] ); $this->assertSame( 100, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['sample'] ); } public function test_never_writes_native_or_flat(): void { $before = $this->snapshot(); TMDO_Drift_Check::run( 'user' ); $this->assertSame( $before, $this->snapshot() ); } // ── fix() (2.2 PR-071) ────────────────────────────────────────────────── private function flat_row( int $user_id ): ?array { global $wpdb; return $wpdb->get_row( $wpdb->prepare( 'SELECT num, cnt, name, flag FROM `' . self::TABLE . '` WHERE user_id = %d', $user_id ), ARRAY_A ); } public function test_fix_rebuilds_drifted_rows_from_native_and_leaves_no_drift(): void { $result = TMDO_Drift_Check::fix( 'user' ); $this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $result['after']['total'] ); $this->assertSame( 'b', $this->flat_row( 2 )['name'] ); $this->assertSame( '5', $this->flat_row( 2 )['cnt'] ); $this->assertSame( 'c', $this->flat_row( 3 )['name'] ); $this->assertSame( array( 'num' => null, 'cnt' => '7', 'name' => null, 'flag' => '0' ), $this->flat_row( 7 ), 'new row: no invented defaults' ); $this->assertSame( array( 'num' => null, 'cnt' => null, 'name' => null, 'flag' => '0' ), $this->flat_row( 4 ), 'no native values: row kept, columns cleared' ); $this->assertSame( 0, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['diff'] ); } public function test_fix_deletes_rows_of_entities_that_no_longer_exist(): void { $this->flat( array( 'user_id' => 99, 'name' => 'ghost' ) ); $result = TMDO_Drift_Check::fix( 'user' ); $this->assertSame( 1, $result['fixed']['deleted_rows'] ); $this->assertNull( $this->flat_row( 99 ) ); $this->assertNotNull( $this->flat_row( 1 ), 'rows of existing users stay' ); } public function test_fix_never_writes_native_and_invalidates_the_row_cache(): void { global $wpdb; $native = md5( wp_json_encode( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ) ) ); TMDO_Cache_Orchestrator::set_row( 'user', 2, 'dc_grp', array( 'name' => 'B' ) ); TMDO_Drift_Check::fix( 'user' ); $this->assertSame( $native, md5( wp_json_encode( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ) ) ) ); $this->assertFalse( TMDO_Cache_Orchestrator::get_row( 'user', 2, 'dc_grp' ) ); } // ── cron_tick() (2.2 PR-073) ──────────────────────────────────────────── private static function set_modes( ?array $modes ): void { $cache = ( new ReflectionClass( WPDO_Mode_Manager::class ) )->getProperty( 'cache' ); $cache->setAccessible( true ); $cache->setValue( null, $modes ); } public function test_the_daily_tick_samples_entities_that_write_to_flat(): void { self::set_modes( array( 'post' => 'disabled', 'user' => 'dual_write', 'term' => 'disabled', 'comment' => 'disabled' ) ); try { TMDO_Drift_Check::cron_tick(); } finally { self::set_modes( null ); } $status = get_option( TMDO_Drift_Check::OPTION ); $this->assertSame( TMDO_Drift_Check::CRON_SAMPLE, $status['user']['dc_grp']['sample'] ); $this->assertSame( 2, $status['user']['dc_grp']['diff'], 'the sample covers all 7 fixture users' ); $this->assertArrayNotHasKey( 'post', $status ); } public function test_the_daily_tick_skips_entities_that_do_not_write_to_flat(): void { self::set_modes( array( 'post' => 'disabled', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) ); try { TMDO_Drift_Check::cron_tick(); } finally { self::set_modes( null ); } $this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) ); } public function test_the_daily_tick_skips_aeav_only_where_flat_is_the_source_of_truth(): void { self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) ); try { TMDO_Drift_Check::cron_tick(); } finally { self::set_modes( null ); } $this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) ); } public function test_fix_refuses_aeav_only_and_leaves_flat_alone(): void { global $wpdb; $flat = md5( wp_json_encode( $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ); self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) ); try { TMDO_Drift_Check::fix( 'user' ); $this->fail( 'fix() must refuse aeav_only' ); } catch ( RuntimeException $e ) { $this->assertStringContainsString( 'aeav_only', $e->getMessage() ); } finally { self::set_modes( null ); } $this->assertSame( $flat, md5( wp_json_encode( $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) ); } // ── resync after writes bypassed flat (2.2 PR-074) ────────────────────── public function test_turning_writes_on_schedules_a_fix_for_that_entity(): void { $this->assertSame( array( 'user' ), TMDO_Drift_Check::resync_after_mode_change( 'user', 'dual_write', 'disabled' ) ); } public function test_other_mode_changes_schedule_nothing(): void { $this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'shadow_read', 'dual_write' ) ); $this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'aeav_only', 'shadow_read' ) ); $this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'disabled', 'dual_write' ) ); } public function test_turning_the_hook_bus_on_schedules_a_fix_for_entities_that_keep_native(): void { self::set_modes( array( 'post' => 'aeav_only', 'user' => 'dual_write', 'term' => 'shadow_read', 'comment' => 'disabled' ) ); try { $on = TMDO_Drift_Check::resync_after_hook_bus_change( '0', '1' ); $off = TMDO_Drift_Check::resync_after_hook_bus_change( '1', '0' ); } finally { self::set_modes( null ); } $this->assertSame( array( 'user', 'term' ), $on ); $this->assertSame( array(), $off ); } public function test_the_scheduled_fix_repairs_the_entity(): void { self::set_modes( array( 'post' => 'disabled', 'user' => 'dual_write', 'term' => 'disabled', 'comment' => 'disabled' ) ); try { TMDO_Drift_Check::run_scheduled_fix( 'user' ); } finally { self::set_modes( null ); } $status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']; $this->assertSame( 0, $status['miss'] + $status['diff'] + $status['extra'] ); } public function test_the_scheduled_fix_leaves_aeav_only_alone(): void { self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) ); try { TMDO_Drift_Check::run_scheduled_fix( 'user' ); } finally { self::set_modes( null ); } $this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) ); $this->assertSame( 'B', $this->flat_row( 2 )['name'], 'flat untouched' ); } public function test_the_resync_triggers_are_hooked(): void { $source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' ); foreach ( array( "add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Drift_Check', 'on_mode_changed' ), 10, 3 );", "add_action( 'update_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_toggled' ), 10, 2 );", "add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );", "add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );", ) as $line ) { $this->assertStringContainsString( $line, $source ); } // Literal: uninstall.php does not load TMDO_Drift_Check. $installer = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-installer.php' ); // One CRON_HOOKS list serves deactivate and uninstall (InstallerCleanupTest covers both). $this->assertSame( 1, substr_count( $installer, "'" . TMDO_Drift_Check::RESYNC_HOOK . "'" ), 'listed in CRON_HOOKS' ); } public function test_the_daily_tick_runs_on_the_daily_health_check_hook(): void { $source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' ); $this->assertStringContainsString( "add_action( 'wpdo_daily_health_check', array( 'TMDO_Drift_Check', 'cron_tick' ) );", $source ); } public function test_rejects_an_unknown_group(): void { $this->expectException( InvalidArgumentException::class ); TMDO_Drift_Check::run( 'user', 'no_such_group' ); } }