Files
2meet-data-optimizer/tests/integration/DriftCheckTest.php
wpdev a26500206a feat(2meet-data-optimizer): backfill writes made while the Hook Bus was off
- When an entity goes from disabled to dual_write / shadow_read, or the Hook Bus flag is switched on, a single WP-Cron event (tmdo_drift_resync, one per entity, 60 s later) runs drift-check --fix for the entities that keep native meta, so values written while flat was bypassed land in flat. aeav_only is never touched.
- A full fix instead of the planned post_modified_gmt increment (decision 2026-09-27): meta-only writes such as WooCommerce's direct stock SQL do not bump post_modified. No wpdo_bridge_off_since.
- The resync hook is cleared on deactivate / uninstall (literal name: uninstall.php does not load TMDO_Drift_Check).
- Verify: DriftCheckTest resync cases (RED before); dev36 end to end with real WP-Cron: write while user disabled → miss 1 → bridge-mode user dual_write schedules tmdo_drift_resync [user] → run → drift 0.
- PR: PR-074
2026-09-27 00:41:43 +08:00

320 lines
14 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* TMDO_Drift_Check (2.2 PR-070): read-only flat-vs-native comparison.
*
* Fixture (user entity, group dc_grp):
* user 1 every key equal in value, differently formatted
* (50 vs 50.000000, serialized array vs JSON, required flag holding
* its default with no native row) → clean
* user 2 name 'b' vs 'B', cnt 5 vs 6 → diff ×2
* user 3 native name, flat row with name NULL → miss
* user 4 no native rows, flat cnt 9 → extra
* user 5 native name '', flat NULL → clean
* user 6 native name longer than a text column, flat NULL → clean
* user 7 native cnt, no flat row → miss
*/
class DriftCheckTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_dc_grp';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/diagnostic/class-tmdo-drift-check.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->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' );
$this->assertSame( 2, substr_count( $installer, "'" . TMDO_Drift_Check::RESYNC_HOOK . "'" ), 'cleared on deactivate and uninstall' );
}
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' );
}
}