d36bb954d1
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
213 lines
8.0 KiB
PHP
213 lines
8.0 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Unit tests for WPDO_Sync_Bridge — zone-aware dual-write dispatcher.
|
|
*
|
|
* Tests early-return guard conditions, write routing, and post cleanup.
|
|
*/
|
|
class SyncBridgeTest extends TestCase {
|
|
|
|
private WPDO_Sync_Bridge $bridge;
|
|
|
|
/** Capture last SQL passed to $wpdb->query(). */
|
|
public static string $last_query = '';
|
|
|
|
/** Configurable return value for $wpdb->get_var(). */
|
|
public static ?string $get_var_return = null;
|
|
|
|
protected function setUp(): void {
|
|
$this->bridge = new WPDO_Sync_Bridge();
|
|
|
|
// Reset globals.
|
|
$GLOBALS['_wp_options'] = [];
|
|
$GLOBALS['_wp_post_types'] = [];
|
|
self::$last_query = '';
|
|
self::$get_var_return = null;
|
|
|
|
// Reset private statics via reflection.
|
|
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
|
|
$ref->getProperty( 'bypassing' )->setValue( null, false );
|
|
$ref->getProperty( 'field_cache' )->setValue( null, [] );
|
|
|
|
// Reset Schema Registry singleton.
|
|
$sr = new ReflectionClass( WPDO_Schema_Registry::class );
|
|
$sr->getProperty( 'instance' )->setValue( null, null );
|
|
|
|
// Reset Feature Flags request cache.
|
|
$ff = new ReflectionClass( WPDO_Feature_Flags::class );
|
|
$ff->getProperty( 'cache' )->setValue( null, null );
|
|
|
|
$this->setup_wpdb_mock();
|
|
}
|
|
|
|
private function setup_wpdb_mock(): void {
|
|
global $wpdb;
|
|
|
|
$wpdb = new class {
|
|
public string $prefix = 'wp_';
|
|
public string $postmeta = 'wp_postmeta';
|
|
public string $posts = 'wp_posts';
|
|
|
|
public function prepare( string $sql, ...$args ): string {
|
|
$i = 0;
|
|
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
|
|
$val = $args[ $i++ ] ?? '';
|
|
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
|
|
}, $sql );
|
|
}
|
|
|
|
public function get_var( string $sql ): ?string {
|
|
return SyncBridgeTest::$get_var_return;
|
|
}
|
|
|
|
public function get_row( string $sql, $output = OBJECT ) { return null; }
|
|
public function get_results( string $sql, $output = OBJECT ): array { return []; }
|
|
public function insert( string $table, array $data, $format = null ): int|false { return 1; }
|
|
public function update( string $table, array $data, array $where, $f = null, $wf = null ): int|false { return 1; }
|
|
public function delete( string $table, array $where, $format = null ): int|false { return 1; }
|
|
|
|
public function query( string $sql ): int|bool {
|
|
SyncBridgeTest::$last_query = $sql;
|
|
return 1;
|
|
}
|
|
};
|
|
}
|
|
|
|
// ── Helper: register a hot field ─────────────────────────────────────────
|
|
|
|
private function register_hot_field( string $post_type = 'hp_listing', string $meta_key = 'hp_price' ): void {
|
|
WPDO_Schema_Registry::instance()->register( 'test', [
|
|
'post_type' => $post_type,
|
|
'meta_key' => $meta_key,
|
|
'zone' => 'hot',
|
|
'column' => $meta_key,
|
|
'type' => 'decimal',
|
|
] );
|
|
}
|
|
|
|
// ── intercept_get: early-return guards ───────────────────────────────────
|
|
|
|
public function test_intercept_get_returns_null_when_bypassing(): void {
|
|
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
|
|
$ref->getProperty( 'bypassing' )->setValue( null, true );
|
|
|
|
$result = $this->bridge->intercept_get( null, 1, 'hp_price', true );
|
|
$this->assertNull( $result );
|
|
}
|
|
|
|
public function test_intercept_get_returns_null_for_zero_post_id(): void {
|
|
$result = $this->bridge->intercept_get( null, 0, 'hp_price', true );
|
|
$this->assertNull( $result );
|
|
}
|
|
|
|
public function test_intercept_get_returns_null_for_empty_meta_key(): void {
|
|
$result = $this->bridge->intercept_get( null, 1, '', true );
|
|
$this->assertNull( $result );
|
|
}
|
|
|
|
public function test_intercept_get_returns_null_when_post_type_unknown(): void {
|
|
// Post ID 99 not in _wp_post_types — get_post_type returns false.
|
|
$result = $this->bridge->intercept_get( null, 99, 'hp_price', true );
|
|
$this->assertNull( $result );
|
|
}
|
|
|
|
public function test_intercept_get_returns_null_when_field_not_registered(): void {
|
|
$GLOBALS['_wp_post_types'][1] = 'hp_listing';
|
|
// No field registered → returns unchanged $value.
|
|
$result = $this->bridge->intercept_get( null, 1, 'unregistered_key', true );
|
|
$this->assertNull( $result );
|
|
}
|
|
|
|
public function test_intercept_get_returns_null_when_module_not_cutover(): void {
|
|
$GLOBALS['_wp_post_types'][1] = 'hp_listing';
|
|
$this->register_hot_field();
|
|
// Module stays idle (not set) → is_read_custom returns false.
|
|
$result = $this->bridge->intercept_get( null, 1, 'hp_price', true );
|
|
$this->assertNull( $result );
|
|
}
|
|
|
|
public function test_intercept_get_returns_zone_value_when_cutover(): void {
|
|
$GLOBALS['_wp_post_types'][2] = 'hp_listing';
|
|
$this->register_hot_field();
|
|
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
|
|
self::$get_var_return = '42';
|
|
|
|
$result = $this->bridge->intercept_get( null, 2, 'hp_price', true );
|
|
// Returns array-wrapped value (WordPress unwraps on $single=true).
|
|
$this->assertSame( [ '42' ], $result );
|
|
}
|
|
|
|
public function test_intercept_get_returns_null_when_zone_returns_null(): void {
|
|
$GLOBALS['_wp_post_types'][3] = 'hp_listing';
|
|
$this->register_hot_field();
|
|
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
|
|
self::$get_var_return = null; // Zone returns nothing.
|
|
|
|
$result = $this->bridge->intercept_get( null, 3, 'hp_price', true );
|
|
$this->assertNull( $result );
|
|
}
|
|
|
|
// ── intercept_update ─────────────────────────────────────────────────────
|
|
|
|
public function test_intercept_update_skips_when_bypassing(): void {
|
|
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
|
|
$ref->getProperty( 'bypassing' )->setValue( null, true );
|
|
|
|
$result = $this->bridge->intercept_update( null, 1, 'hp_price', '99', '' );
|
|
$this->assertNull( $result );
|
|
$this->assertEmpty( self::$last_query );
|
|
}
|
|
|
|
public function test_intercept_update_passes_through_when_no_field_registered(): void {
|
|
$GLOBALS['_wp_post_types'][1] = 'hp_listing';
|
|
// No field registered → returns $check unchanged.
|
|
$result = $this->bridge->intercept_update( null, 1, 'hp_price', '99', '' );
|
|
$this->assertNull( $result );
|
|
}
|
|
|
|
public function test_intercept_update_writes_to_zone_when_write_active(): void {
|
|
$GLOBALS['_wp_post_types'][5] = 'hp_listing';
|
|
$this->register_hot_field();
|
|
WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' );
|
|
|
|
$this->bridge->intercept_update( null, 5, 'hp_price', '150', '' );
|
|
|
|
// Zone Hot set() executes an UPSERT query.
|
|
$this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', self::$last_query );
|
|
}
|
|
|
|
// ── intercept_add ────────────────────────────────────────────────────────
|
|
|
|
public function test_intercept_add_writes_when_module_write_active(): void {
|
|
$GLOBALS['_wp_post_types'][6] = 'hp_listing';
|
|
$this->register_hot_field();
|
|
WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' );
|
|
|
|
$this->bridge->intercept_add( null, 6, 'hp_price', '200', false );
|
|
|
|
$this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', self::$last_query );
|
|
}
|
|
|
|
// ── cleanup_post ─────────────────────────────────────────────────────────
|
|
|
|
public function test_cleanup_post_does_nothing_for_unknown_post_type(): void {
|
|
// Post ID 999 has no type → early return.
|
|
$this->bridge->cleanup_post( 999 );
|
|
$this->assertEmpty( self::$last_query );
|
|
}
|
|
|
|
public function test_cleanup_post_deletes_hot_zone_data(): void {
|
|
$GLOBALS['_wp_post_types'][10] = 'hp_listing';
|
|
$this->register_hot_field();
|
|
|
|
$this->bridge->cleanup_post( 10 );
|
|
|
|
// WPDO_Zone_Hot::delete() calls $wpdb->delete() — but our mock captures query().
|
|
// The hot delete uses $wpdb->delete(), not query(). Just assert no exception thrown.
|
|
$this->assertTrue( true );
|
|
}
|
|
}
|