6c782282c7
- TMDO_Drift_Check::cron_tick() runs on the existing wpdo_daily_health_check event (no new schedule to add or clear): every entity type whose flat tables are written (dual_write or higher) is checked on 500 random entities per group, and the outcome replaces the recorded status, so a group found drifting leaves the router that day. - Verify: DriftCheckTest cron cases (RED: method / hook missing); dev36 with all four entities in dual_write: one tick over 22 groups in 10 s, 0 drift, status option restored. - PR: PR-073
228 lines
10 KiB
PHP
228 lines
10 KiB
PHP
<?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_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' );
|
||
}
|
||
}
|