query( 'DROP TABLE IF EXISTS `' . self::TABLE_FAVORITE . '`' ); $wpdb->query( "CREATE TABLE `" . self::TABLE_FAVORITE . "` ( comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, listing_id BIGINT UNSIGNED NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY unique_user_listing (user_id, listing_id), KEY idx_listing (listing_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_MESSAGE . '`' ); $wpdb->query( "CREATE TABLE `" . self::TABLE_MESSAGE . "` ( comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY, sender_id BIGINT UNSIGNED NOT NULL, recipient_id BIGINT UNSIGNED NOT NULL, listing_id BIGINT UNSIGNED NOT NULL, is_read TINYINT(1) NOT NULL DEFAULT 0, sent_at DATETIME NOT NULL, KEY idx_recipient_unread (recipient_id, is_read), KEY idx_thread (listing_id, sent_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); } public static function tearDownAfterClass(): void { global $wpdb; $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_FAVORITE . '`' ); $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_MESSAGE . '`' ); } protected function setUp(): void { global $wpdb; $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE_FAVORITE . '`' ); $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE_MESSAGE . '`' ); // Clear FSM module state between tests. $GLOBALS['_wp_options']['wpdo_features'] = array(); } // ── Schema tests ──────────────────────────────────────────────────────── public function test_favorite_table_has_unique_user_listing_constraint(): void { global $wpdb; $row = $wpdb->get_row( 'SHOW CREATE TABLE `' . self::TABLE_FAVORITE . '`', 'ARRAY_A' ); $this->assertNotNull( $row ); $ddl = $row['Create Table'] ?? ''; $this->assertStringContainsString( 'UNIQUE KEY', $ddl ); $this->assertStringContainsString( 'unique_user_listing', $ddl ); } public function test_message_table_has_recipient_unread_index(): void { global $wpdb; $row = $wpdb->get_row( 'SHOW CREATE TABLE `' . self::TABLE_MESSAGE . '`', 'ARRAY_A' ); $this->assertNotNull( $row ); $ddl = $row['Create Table'] ?? ''; $this->assertStringContainsString( 'idx_recipient_unread', $ddl ); $this->assertStringContainsString( 'recipient_id', $ddl ); } // ── mirror_insert real roundtrip ──────────────────────────────────────── public function test_favorite_mirror_insert_writes_real_row(): void { global $wpdb; $adapter = new WPDO_HivePress_Favorites_Adapter(); $comment = new stdClass(); $comment->comment_type = 'hp_favorite'; $comment->user_id = 11; $comment->comment_post_ID = 222; $comment->comment_date = '2026-05-04 12:00:00'; $adapter->mirror_insert( 1001, $comment ); $rows = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_FAVORITE . '`' ); $this->assertSame( 1, $rows ); $saved = $wpdb->get_row( 'SELECT * FROM `' . self::TABLE_FAVORITE . '` WHERE comment_id = 1001', 'ARRAY_A' ); $this->assertSame( '11', (string) $saved['user_id'] ); $this->assertSame( '222', (string) $saved['listing_id'] ); } public function test_favorite_unique_constraint_blocks_double_tap(): void { global $wpdb; $adapter = new WPDO_HivePress_Favorites_Adapter(); // Two different comment_ids but same user+listing — UNIQUE should win. $comment = new stdClass(); $comment->comment_type = 'hp_favorite'; $comment->user_id = 7; $comment->comment_post_ID = 99; $comment->comment_date = '2026-05-04 12:00:00'; $adapter->mirror_insert( 2001, $comment ); $adapter->mirror_insert( 2002, $comment ); // Race-style duplicate. $rows = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_FAVORITE . '` WHERE user_id = 7 AND listing_id = 99' ); $this->assertSame( 1, $rows, 'UNIQUE(user_id, listing_id) must reject duplicate.' ); } public function test_message_mirror_insert_promotes_recipient_id(): void { global $wpdb; $adapter = new WPDO_HivePress_Messages_Adapter(); $comment = new stdClass(); $comment->comment_type = 'hp_message'; $comment->user_id = 5; // sender $comment->comment_karma = 42; // recipient (HP hack) $comment->comment_post_ID = 99; $comment->comment_approved = 0; $comment->comment_date = '2026-05-04 12:00:00'; $adapter->mirror_insert( 3001, $comment ); $saved = $wpdb->get_row( 'SELECT * FROM `' . self::TABLE_MESSAGE . '` WHERE comment_id = 3001', 'ARRAY_A' ); $this->assertNotNull( $saved ); $this->assertSame( '42', (string) $saved['recipient_id'] ); $this->assertSame( '5', (string) $saved['sender_id'] ); // recipient_unread index supports the dashboard query. $unread_count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_MESSAGE . '` WHERE recipient_id = 42 AND is_read = 0' ); $this->assertSame( 1, $unread_count ); } // ── FSM lifecycle ─────────────────────────────────────────────────────── public function test_fsm_lifecycle_progression(): void { // Replicates a typical migration: idle → dual_write → backfill → ... // → cleanup → complete. Each step verified via Feature_Flags state. WPDO_Feature_Flags::set( 'comment_hp_favorite', 'idle' ); $this->assertSame( 'idle', WPDO_Feature_Flags::get( 'comment_hp_favorite' ) ); $this->assertFalse( WPDO_Feature_Flags::is_query_active( 'comment_hp_favorite' ) ); foreach ( array( 'dual_write', 'backfill', 'verify' ) as $state ) { WPDO_Feature_Flags::set( 'comment_hp_favorite', $state ); // Reads still go to legacy until cutover. $this->assertFalse( WPDO_Feature_Flags::is_query_active( 'comment_hp_favorite' ), "State $state should not be query-active yet" ); } WPDO_Feature_Flags::set( 'comment_hp_favorite', 'cutover' ); $this->assertTrue( WPDO_Feature_Flags::is_query_active( 'comment_hp_favorite' ) ); WPDO_Feature_Flags::set( 'comment_hp_favorite', 'cleanup' ); $this->assertTrue( WPDO_Feature_Flags::is_query_active( 'comment_hp_favorite' ) ); WPDO_Feature_Flags::set( 'comment_hp_favorite', 'complete' ); $this->assertTrue( WPDO_Feature_Flags::is_query_active( 'comment_hp_favorite' ) ); } public function test_cron_optimizer_respects_module_state(): void { WPDO_HivePress_Cron_Optimizer::reset_for_tests(); // Module idle → optimizer must not query. WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' ); $count = WPDO_HivePress_Cron_Optimizer::maybe_run(); $this->assertSame( 0, $count, 'Optimizer must short-circuit when module is idle.' ); } } // Production adapters use `$wpdb->prefix . self::TABLE` to compose the full // table name. Integration test bootstrap sets prefix to `wp_itest_`, so the // production mirror_insert() naturally writes to wp_itest_wpdo_comment_hp_*. // No subclassing or test-double needed — the production code path is exercised // directly against real MariaDB with isolated prefix.