diff --git a/tests/integration/AuditLoggerIntegrationTest.php b/tests/integration/AuditLoggerIntegrationTest.php new file mode 100644 index 0000000..e843add --- /dev/null +++ b/tests/integration/AuditLoggerIntegrationTest.php @@ -0,0 +1,169 @@ +query( 'DROP TABLE IF EXISTS `' . self::AUDIT_TABLE . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::AUDIT_TABLE . '` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `ts` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\', + `user_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `entity_type` varchar(20) NOT NULL DEFAULT \'\', + `entity_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `group_name` varchar(50) NOT NULL DEFAULT \'\', + `meta_key` varchar(255) NOT NULL DEFAULT \'\', + `action` varchar(20) NOT NULL DEFAULT \'\', + `op` varchar(20) NOT NULL DEFAULT \'\', + `value_before` longtext, + `value_after` longtext, + `source` varchar(20) NOT NULL DEFAULT \'\', + `trace_id` varchar(36) NOT NULL DEFAULT \'\', + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + + // Groups only register once an adapter exists for the entity type. + WPDO_Entity_Registry::register_adapter( self::TYPE, new WPDO_Adapter_User() ); + WPDO_Member_Fields::register_entity_fields(); + WPDO_Audit_Logger::init(); + } + + /** + * Detach the listener so later test classes' writes are not audited. + * + * The table is deliberately left in place: Audit_Logger::init() may already + * have been called elsewhere, and a dropped table would turn every later + * managed write into a DB error. + */ + public static function tearDownAfterClass(): void { + global $wpdb; + remove_action( 'wpdo_after_write', array( 'TMDO_Audit_Logger', 'on_write' ), 10 ); + remove_action( 'wpdo_after_delete', array( 'TMDO_Audit_Logger', 'on_delete' ), 10 ); + $wpdb->query( 'TRUNCATE TABLE `' . self::AUDIT_TABLE . '`' ); + } + + /** + * Empty the audit table before each case. + */ + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::AUDIT_TABLE . '`' ); + } + + /** + * Fire the action the Hook Bus emits after a managed write. + * + * @param string $meta_key Meta key. + * @param mixed $after New value. + * @return void + */ + private function fire_write( string $meta_key, $after ): void { + // Same argument order the Hook Bus uses (class-tmdo-hook-bus.php:114): + // entity_type, entity_id, meta_key, meta_value, upsert_result, op, before_value. + do_action( 'wpdo_after_write', self::TYPE, 42, $meta_key, $after, 1, 'update', null ); + } + + /** + * The listener must write a row without fataling — this is the regression + * guard for the missing TMDO_Logger::trace_id(). + */ + public function test_after_write_inserts_audit_row(): void { + global $wpdb; + $this->fire_write( 'nickname', 'audit-test' ); + + $rows = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::AUDIT_TABLE . '`' ); + $this->assertSame( 1, $rows, 'wpdo_after_write must produce exactly one audit row' ); + } + + /** + * group_name / action are the two columns added in SCHEMA_VERSION 2.1.0; + * write_row() has always written them, so a missing column is a silent + * "Unknown column" failure. + */ + public function test_audit_row_populates_group_name_and_action(): void { + global $wpdb; + $this->fire_write( 'nickname', 'audit-test' ); + + $row = $wpdb->get_row( 'SELECT * FROM `' . self::AUDIT_TABLE . '` ORDER BY id DESC LIMIT 1', ARRAY_A ); + $this->assertSame( self::TYPE, $row['entity_type'] ); + $this->assertSame( 'nickname', $row['meta_key'] ); + $this->assertSame( self::GROUP, $row['group_name'], 'group_name column must be populated' ); + $this->assertSame( 'write', $row['action'], 'action column must be populated' ); + } + + /** + * trace_id must be a UUIDv4 and stable within one request, so every row + * written by the same request can be correlated. + */ + public function test_trace_id_is_uuid_v4_and_shared_within_request(): void { + global $wpdb; + $this->fire_write( 'nickname', 'first' ); + $this->fire_write( 'first_name', 'second' ); + + $ids = $wpdb->get_col( 'SELECT trace_id FROM `' . self::AUDIT_TABLE . '`' ); + $this->assertCount( 2, $ids ); + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $ids[0], + 'trace_id must be a UUIDv4' + ); + $this->assertSame( $ids[0], $ids[1], 'all rows in one request share a trace_id' ); + } + + /** + * Unregistered keys carry no field definition and must not be audited. + */ + public function test_unregistered_key_is_not_audited(): void { + global $wpdb; + do_action( 'wpdo_after_write', self::TYPE, 42, 'not_registered_key', 'x', 1, 'update', null ); + + $rows = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::AUDIT_TABLE . '`' ); + $this->assertSame( 0, $rows ); + } +} diff --git a/tests/integration/HookBusIntegrationTest.php b/tests/integration/HookBusIntegrationTest.php new file mode 100644 index 0000000..4c8c1db --- /dev/null +++ b/tests/integration/HookBusIntegrationTest.php @@ -0,0 +1,367 @@ +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' ); + } + + public function test_update_writes_flat_and_returns_null_in_dual_write_mode(): void { + 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->assertSame( 'Carol', $this->read_flat_first_name( 3 ) ); + $this->assertNull( $result, 'dual_write must return null so WP continues to write EAV' ); + } + + 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' ); + } + + 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' ); + } +} diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index a44ddd7..f57da91 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -257,6 +257,11 @@ if ( ! function_exists( 'remove_filter' ) ) { return true; } } +if ( ! function_exists( 'remove_action' ) ) { + function remove_action( string $hook, $cb, int $p = 10 ): bool { + return remove_filter( $hook, $cb, $p ); + } +} if ( ! function_exists( 'apply_filters' ) ) { function apply_filters( string $hook, $value, ...$args ) { foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) { @@ -570,6 +575,8 @@ require_once TMDO_PATH . 'includes/engine/class-tmdo-schema-manager.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php'; require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php'; require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php'; require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';