Files
2meet-data-optimizer/tests/integration/HookBusIntegrationTest.php
T
wpdev 1d70d13f45 fix(2meet-data-optimizer): close the gaps in deferred table upgrades found in the 2.3 review
- a deferred group whose stored definition matches again (reverted, or
  upgraded by another process) goes through end_deferral() instead of staying
  native for good (review F2)
- the tmdo_ensure_group_indexes handler upgrades deferred tables itself: with
  ALTERNATE_WP_CRON the event runs after init in a visitor's request (F3)
- a new group table created while the entity writes to flat stays native
  until the drift fix has filled it from native meta; an opted-in HivePress
  attribute no longer routes searches to an empty table (F6)
- direct_read() returns native values for a deferred group (F10); the Hook
  Bus ignores deferral in aeav_only, as the router does
- dbDelta failures: the definition is recorded only when every column exists
  (schema.upgrade_failed otherwise), so a failed ALTER is retried and the
  group is not released onto missing columns
- test bootstrap: get_metadata() reads the real meta table
- Verify: SchemaUpgradeDeferralTest (6), Hook Bus direct read, integration
  587, unit 703, phpcs, stan
- PR: PR-114 (2.3 acceptance correctness review)
2026-09-27 17:30:36 +08:00

491 lines
18 KiB
PHP

<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
if ( ! defined( 'WPDO_CACHE_GROUP' ) ) {
define( 'WPDO_CACHE_GROUP', 'wpdo' );
}
if ( ! function_exists( 'current_filter' ) ) {
/**
* Stub: returns $GLOBALS['_wp_current_filter'] so tests can set the simulated
* filter context before calling WPDO_Hook_Bus::intercept_update/delete.
*/
function current_filter(): string {
return $GLOBALS['_wp_current_filter'] ?? '';
}
}
/**
* Integration test: WPDO_Hook_Bus intercept paths (P1-8 coverage).
*
* Verifies the three core interception methods (intercept_update, intercept_get,
* intercept_delete) against real MariaDB, covering:
* - aeav_only: flat write + EAV short-circuit (returns true)
* - dual_write: flat write + EAV passthrough (returns null)
* - disabled: full passthrough, no flat write
* - unregistered key: passthrough
* - direct_read: bypass-filter read path
* - cleanup_entity: row deletion on entity removal
*
* current_filter() is stubbed via $GLOBALS['_wp_current_filter'] — tests set
* this before calling intercept_update/delete to simulate the WP filter context
* that would normally be provided by the metadata filter pipeline.
*/
class HookBusIntegrationTest extends TestCase {
private const TYPE = 'user';
private const GROUP = 'core_profile';
private const FLAT_TABLE = 'wp_itest_wpdo_user_core_profile';
private const ERRORS_TABLE = 'wp_itest_wpdo_errors';
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
$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/integrations/class-tmdo-member-fields.php',
'includes/engine/class-tmdo-hook-bus.php',
) as $file ) {
require_once $base . $file;
}
// Clear stale Schema_Manager cache entries for tables we manage.
// We only remove the specific entry rather than replacing the entire cache
// to avoid corrupting cached state that other test classes depend on.
$ref = new ReflectionClass( WPDO_Schema_Manager::class );
$cache = $ref->getProperty( 'table_exists_cache' );
$cache->setAccessible( true );
$existing = $cache->getValue( null );
unset( $existing[ self::FLAT_TABLE ] );
$cache->setValue( null, $existing );
// Errors table (required by WPDO_Logger).
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::ERRORS_TABLE . '`' );
$wpdb->query(
'CREATE TABLE `' . self::ERRORS_TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`module` varchar(100) NOT NULL DEFAULT \'\',
`zone` varchar(20) NOT NULL DEFAULT \'\',
`hook` varchar(255) NOT NULL DEFAULT \'\',
`message` text NOT NULL,
`context` longtext,
`created_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
// Flat table for user core_profile.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT_TABLE . '`' );
$wpdb->query(
'CREATE TABLE `' . self::FLAT_TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL,
`nickname` varchar(255) DEFAULT NULL,
`first_name` varchar(255) DEFAULT NULL,
`last_name` varchar(255) DEFAULT NULL,
`description` text 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_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
// Mark table as known-good in Schema_Manager cache (add, don't replace).
$existing2 = $cache->getValue( null ) ?? array();
$existing2[ self::FLAT_TABLE ] = true;
$cache->setValue( null, $existing2 );
// Register adapter + all user field groups (direct call — add_action is a no-op
// in the integration test environment, so ::register() would not fire).
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( self::TYPE, new WPDO_Adapter_User() );
WPDO_Member_Fields::register_entity_fields();
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT_TABLE . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::ERRORS_TABLE . '`' );
// Reset Mode_Manager + Entity_Registry so this test class doesn't leak
// user mode state into subsequent integration test classes.
$mref = new ReflectionClass( WPDO_Mode_Manager::class );
$mcache = $mref->getProperty( 'cache' );
$mcache->setAccessible( true );
$mcache->setValue( null, null );
WPDO_Entity_Registry::init();
// Clear Schema_Manager table_exists_cache entirely so that the `false`
// entries written by cleanup_entity (for non-existent group tables) don't
// trick subsequent test classes into thinking those tables don't exist after
// they create them.
$sref = new ReflectionClass( WPDO_Schema_Manager::class );
$scache = $sref->getProperty( 'table_exists_cache' );
$scache->setAccessible( true );
$scache->setValue( null, array() );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::FLAT_TABLE . '`' );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_cache'] = array();
$GLOBALS['_wp_current_filter'] = '';
// Default: user mode = aeav_only (most tests use this).
self::set_user_mode( WPDO_Mode_Manager::MODE_AEAV_ONLY );
// Clear Cache_Orchestrator L1.
$ref = new ReflectionClass( WPDO_Cache_Orchestrator::class );
$l1 = $ref->getProperty( 'l1_cache' );
$l1->setAccessible( true );
$l1->setValue( null, array() );
}
/**
* Inject user mode into Mode_Manager's static cache (bypasses wp_options
* and Cache_Orchestrator dependency of Mode_Manager::set()).
*/
private static function set_user_mode( string $mode ): void {
$ref = new ReflectionClass( WPDO_Mode_Manager::class );
$cache = $ref->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, array(
'user' => $mode,
'post' => 'disabled',
'term' => 'dual_write',
'comment' => 'dual_write',
) );
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function read_flat_first_name( int $user_id ): ?string {
global $wpdb;
return $wpdb->get_var(
$wpdb->prepare(
'SELECT `first_name` FROM `' . self::FLAT_TABLE . '` WHERE `user_id` = %d',
$user_id
)
);
}
// ── Tests: intercept_update ───────────────────────────────────────────────
public function test_update_writes_to_flat_in_aeav_only_mode(): void {
$GLOBALS['_wp_current_filter'] = 'update_user_metadata';
WPDO_Hook_Bus::intercept_update( null, 1, 'first_name', 'Alice', '' );
$this->assertSame( 'Alice', $this->read_flat_first_name( 1 ) );
}
public function test_update_returns_true_in_aeav_only_mode(): void {
$GLOBALS['_wp_current_filter'] = 'update_user_metadata';
$result = WPDO_Hook_Bus::intercept_update( null, 2, 'first_name', 'Bob', '' );
$this->assertTrue( $result, 'aeav_only must return true to short-circuit EAV write' );
}
/**
* dual_write: the pre-filter only lets WP write native; flat is synced
* from native afterwards (updated_/added_ actions → sync_from_native).
*/
public function test_update_leaves_flat_to_the_native_sync_in_dual_write_mode(): void {
global $wpdb;
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
$GLOBALS['_wp_current_filter'] = 'update_user_metadata';
$result = WPDO_Hook_Bus::intercept_update( null, 3, 'first_name', 'Carol', '' );
$this->assertNull( $result, 'dual_write must return null so WP continues to write EAV' );
$this->assertNull( $this->read_flat_first_name( 3 ), 'no flat write before native is written' );
$this->with_usermeta(
function () use ( $wpdb ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 3, 'meta_key' => 'first_name', 'meta_value' => 'Carol' ) );
WPDO_Hook_Bus::sync_from_native( 'user', 3, 'first_name', 'Carol', 'update' );
$this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) );
}
);
}
/**
* A group whose table upgrade waits for WP-CLI / WP-Cron (2.3 PR-107)
* stays native: its table may lack the new columns.
*/
public function test_a_group_waiting_for_its_table_upgrade_stays_native(): void {
global $wpdb;
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/core_profile' => time() ) );
try {
$this->with_usermeta(
function () use ( $wpdb ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 4, 'meta_key' => 'first_name', 'meta_value' => 'Dora' ) );
WPDO_Hook_Bus::sync_from_native( 'user', 4, 'first_name', 'Dora', 'update' );
$this->assertNull( $this->read_flat_first_name( 4 ) );
$this->assertSame( 'Dora', WPDO_Hook_Bus::direct_read( 'user', 4, 'first_name' ), 'direct reads return the native value' );
}
);
} finally {
delete_option( TMDO_Schema_Manager::DEFERRED_OPTION );
}
}
/**
* delete_all passes object ID 0: the delete_ action records the touched
* users from the meta IDs, the deleted_ action re-syncs each to NULL.
*/
public function test_delete_all_resyncs_every_touched_entity_in_dual_write_mode(): void {
global $wpdb;
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 21, 'first_name' => 'A' ) );
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 22, 'first_name' => 'B' ) );
$this->with_usermeta(
function () use ( $wpdb ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 21, 'meta_key' => 'first_name', 'meta_value' => 'A' ) );
$mid_a = (int) $wpdb->insert_id;
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 22, 'meta_key' => 'first_name', 'meta_value' => 'B' ) );
$mid_b = (int) $wpdb->insert_id;
$GLOBALS['_wp_current_filter'] = 'delete_user_meta';
WPDO_Hook_Bus::before_meta_deleted( array( $mid_a, $mid_b ), 0, 'first_name', '' );
$wpdb->query( "DELETE FROM `{$wpdb->usermeta}` WHERE meta_key = 'first_name'" );
$GLOBALS['_wp_current_filter'] = 'deleted_user_meta';
WPDO_Hook_Bus::on_meta_deleted( array( $mid_a, $mid_b ), 0, 'first_name', '' );
$this->assertNull( $this->read_flat_first_name( 21 ) );
$this->assertNull( $this->read_flat_first_name( 22 ) );
}
);
}
/**
* Deleting an entity drops its flat row first (adapter delete hook), then
* WordPress deletes each meta row; syncing those deletions must not bring
* the row back as all NULL.
*/
public function test_sync_of_a_deleted_key_does_not_recreate_a_missing_row(): void {
global $wpdb;
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
$this->with_usermeta(
function () use ( $wpdb ) {
WPDO_Hook_Bus::sync_from_native( 'user', 31, 'first_name', 'Gone', 'delete' );
$this->assertSame( '0', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '` WHERE user_id = 31' ) );
}
);
}
/**
* Run $fn with a native usermeta table, dropped afterwards.
*/
private function with_usermeta( callable $fn ): void {
global $wpdb;
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$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) ) ENGINE=InnoDB" );
try {
$fn();
} finally {
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->usermeta}`" );
}
}
public function test_update_passthrough_for_unregistered_key(): void {
$GLOBALS['_wp_current_filter'] = 'update_user_metadata';
$sentinel = new stdClass();
$result = WPDO_Hook_Bus::intercept_update( $sentinel, 4, '_nonexistent_key_xyz', 'x', '' );
$this->assertSame( $sentinel, $result, 'unregistered key must return $check unchanged' );
$this->assertNull( $this->read_flat_first_name( 4 ), 'unregistered key must not write flat table' );
}
public function test_update_passthrough_in_disabled_mode(): void {
self::set_user_mode( WPDO_Mode_Manager::MODE_DISABLED );
$GLOBALS['_wp_current_filter'] = 'update_user_metadata';
$result = WPDO_Hook_Bus::intercept_update( null, 5, 'first_name', 'Dave', '' );
$this->assertNull( $result, 'disabled mode must return $check unchanged' );
$this->assertNull( $this->read_flat_first_name( 5 ), 'disabled mode must not write flat table' );
}
public function test_update_passthrough_when_filter_context_unresolvable(): void {
$GLOBALS['_wp_current_filter'] = ''; // no active filter
$sentinel = new stdClass();
$result = WPDO_Hook_Bus::intercept_update( $sentinel, 6, 'first_name', 'Eve', '' );
$this->assertSame( $sentinel, $result, 'unknown filter context must return $check unchanged' );
}
// ── Tests: intercept_get ─────────────────────────────────────────────────
public function test_get_reads_from_flat_in_aeav_only_mode(): void {
// Seed the flat table directly.
global $wpdb;
$wpdb->query(
$wpdb->prepare(
'INSERT INTO `' . self::FLAT_TABLE . '` (`user_id`, `first_name`) VALUES (%d, %s)',
10,
'Frank'
)
);
// intercept_get accepts $meta_type as 5th arg — bypasses current_filter().
$result = WPDO_Hook_Bus::intercept_get( null, 10, 'first_name', true, 'user' );
$this->assertSame( array( 'Frank' ), $result, 'intercept_get must return [value] array in aeav_only mode' );
}
public function test_get_passthrough_in_dual_write_mode(): void {
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
$result = WPDO_Hook_Bus::intercept_get( null, 11, 'first_name', true, 'user' );
$this->assertNull( $result, 'dual_write does not read from flat — must return $check' );
}
/**
* shadow_read serves native meta: update_metadata() reads the old value
* through this filter, and serving the just-written flat value made WP
* skip the native write (dev36, 2026-09-26). Flat is only compared.
*/
public function test_get_serves_native_in_shadow_read_mode_even_with_a_flat_value(): void {
global $wpdb;
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 12, 'first_name' => 'Flat' ) );
self::set_user_mode( WPDO_Mode_Manager::MODE_SHADOW_READ );
foreach ( array( 0.0, 1.0 ) as $rate ) {
$GLOBALS['_wp_filter_callbacks']['tmdo_shadow_compare_sample_rate'] = array(
static function () use ( $rate ) {
return $rate;
},
);
$result = WPDO_Hook_Bus::intercept_get( null, 12, 'first_name', true, 'user' );
$this->assertNull( $result, "shadow_read must return \$check (sample rate {$rate})" );
}
unset( $GLOBALS['_wp_filter_callbacks']['tmdo_shadow_compare_sample_rate'] );
}
public function test_get_returns_empty_string_when_column_is_null(): void {
global $wpdb;
$wpdb->query(
$wpdb->prepare(
'INSERT INTO `' . self::FLAT_TABLE . '` (`user_id`, `first_name`) VALUES (%d, NULL)',
12
)
);
$result = WPDO_Hook_Bus::intercept_get( null, 12, 'first_name', true, 'user' );
$this->assertSame( '', $result );
}
// ── Tests: intercept_delete ───────────────────────────────────────────────
public function test_delete_clears_column_to_null_in_aeav_only_mode(): void {
global $wpdb;
$wpdb->query(
$wpdb->prepare(
'INSERT INTO `' . self::FLAT_TABLE . '` (`user_id`, `first_name`) VALUES (%d, %s)',
20,
'Grace'
)
);
$GLOBALS['_wp_current_filter'] = 'delete_user_metadata';
WPDO_Hook_Bus::intercept_delete( null, 20, 'first_name', '', false );
$this->assertNull( $this->read_flat_first_name( 20 ), 'delete must set column to NULL (not drop row)' );
}
public function test_delete_returns_true_in_aeav_only_mode(): void {
global $wpdb;
$wpdb->query(
$wpdb->prepare(
'INSERT INTO `' . self::FLAT_TABLE . '` (`user_id`, `first_name`) VALUES (%d, %s)',
21,
'Hank'
)
);
$GLOBALS['_wp_current_filter'] = 'delete_user_metadata';
$result = WPDO_Hook_Bus::intercept_delete( null, 21, 'first_name', '', false );
$this->assertTrue( $result, 'aeav_only delete must return true to short-circuit EAV delete' );
}
// ── Tests: direct_read ───────────────────────────────────────────────────
public function test_direct_read_returns_written_value(): void {
$GLOBALS['_wp_current_filter'] = 'update_user_metadata';
WPDO_Hook_Bus::intercept_update( null, 30, 'first_name', 'Ivan', '' );
// Flush cache so direct_read goes to DB.
$ref = new ReflectionClass( WPDO_Cache_Orchestrator::class );
$l1 = $ref->getProperty( 'l1_cache' );
$l1->setAccessible( true );
$l1->setValue( null, array() );
$GLOBALS['_wp_cache'] = array();
$value = WPDO_Hook_Bus::direct_read( 'user', 30, 'first_name' );
$this->assertSame( 'Ivan', $value );
}
public function test_direct_read_returns_null_for_unknown_key(): void {
$value = WPDO_Hook_Bus::direct_read( 'user', 31, '_nonexistent_key_xyz' );
$this->assertNull( $value );
}
// ── Tests: cleanup_entity ────────────────────────────────────────────────
public function test_cleanup_entity_removes_flat_rows(): void {
global $wpdb;
$wpdb->query(
$wpdb->prepare(
'INSERT INTO `' . self::FLAT_TABLE . '` (`user_id`, `first_name`) VALUES (%d, %s)',
40,
'Judy'
)
);
WPDO_Hook_Bus::cleanup_entity( 'user', 40 );
$count = (int) $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '` WHERE `user_id` = %d',
40
)
);
$this->assertSame( 0, $count, 'cleanup_entity must delete all flat rows for the entity' );
}
}