query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_date DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci" ); foreach ( array( self::A, self::B ) as $table ) { $wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, k VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" ); } $wpdb->query( "INSERT INTO `{$wpdb->posts}` VALUES (1, 'hp_listing', 'publish', '2026-01-01 10:00:00'), (2, 'hp_listing', 'draft', '2026-02-01 10:00:00'), (3, 'hp_listing', 'pending', '2026-03-01 10:00:00')" ); $wpdb->query( 'INSERT INTO `' . self::A . "` (post_id, k) VALUES (1, 'a1'), (2, 'a2')" ); $wpdb->query( 'INSERT INTO `' . self::B . "` (post_id, k) VALUES (1, 'b1')" ); $GLOBALS['_wp_options'] = array(); $GLOBALS['_wp_cache'] = array(); $GLOBALS['_wp_posts'] = array(); $GLOBALS['_wp_current_filter'] = ''; $ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' ); $ref->setAccessible( true ); $ref->setValue( null, array() ); WPDO_Entity_Registry::init(); WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); WPDO_Entity_Registry::register_group( 'post', 'ps_a', array( array( 'key' => 'k', 'type' => 'text' ) ) ); WPDO_Entity_Registry::register_group( 'post', 'ps_b', array( array( 'key' => 'k2', 'type' => 'text' ) ) ); } protected function tearDown(): void { self::drop(); WPDO_Entity_Registry::init(); $ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); $ref->setAccessible( true ); $ref->setValue( null, null ); } private static function drop(): void { global $wpdb; foreach ( array( $wpdb->posts, self::A, self::B ) as $table ) { $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); } } /** @return array post_id → [status, date] */ private function copies( string $table ): array { global $wpdb; $out = array(); foreach ( (array) $wpdb->get_results( "SELECT post_id, post_status, post_date FROM `{$table}` ORDER BY post_id", ARRAY_A ) as $row ) { $out[ (int) $row['post_id'] ] = array( $row['post_status'], $row['post_date'] ); } return $out; } private function has_index( string $table ): bool { global $wpdb; return in_array( 'idx_post_status_date', array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ), true ); } public function test_run_adds_the_columns_copies_every_row_and_marks_groups_ready(): void { $report = TMDO_Post_Status_Sync::run( false, 1 ); $this->assertSame( array( 1 => array( 'publish', '2026-01-01 10:00:00' ), 2 => array( 'draft', '2026-02-01 10:00:00' ), ), $this->copies( self::A ) ); $this->assertSame( array( 1 => array( 'publish', '2026-01-01 10:00:00' ) ), $this->copies( self::B ) ); $this->assertTrue( $this->has_index( self::A ) ); $this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) ); $this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_b' ) ); $this->assertSame( 0, TMDO_Post_Status_Sync::mismatches( 'ps_a' ) ); $this->assertSame( 2, $report['ps_a']['mismatched'] ); } public function test_copies_use_the_posts_collation_so_they_compare_with_posts(): void { global $wpdb; TMDO_Post_Status_Sync::run(); $column = $wpdb->get_row( 'SHOW FULL COLUMNS FROM `' . self::A . "` LIKE 'post_status'", ARRAY_A ); $this->assertSame( 'utf8mb4_unicode_520_ci', $column['Collation'] ); $wpdb->query( 'UPDATE `' . self::A . "` SET post_status = 'stale' WHERE post_id = 2" ); $this->assertSame( 1, TMDO_Post_Status_Sync::mismatches( 'ps_a' ) ); } public function test_dry_run_only_counts(): void { global $wpdb; $report = TMDO_Post_Status_Sync::run( true ); $this->assertSame( 2, $report['ps_a']['mismatched'] ); $this->assertSame( 1, $report['ps_b']['mismatched'] ); $this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) ); $this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_a' ) ); } public function test_status_transition_updates_the_post_in_every_group_table(): void { TMDO_Post_Status_Sync::run(); TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) ); $this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::A )[1] ); $this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::B )[1] ); $this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2], 'other posts untouched' ); } public function test_a_group_whose_table_was_dropped_is_forgotten(): void { global $wpdb; TMDO_Post_Status_Sync::run(); $wpdb->query( 'DROP TABLE `' . self::B . '`' ); $ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' ); $ref->setAccessible( true ); $ref->setValue( null, array() ); $wpdb->last_error = ''; TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) ); $this->assertSame( '', (string) $wpdb->last_error, 'no UPDATE of a missing table' ); $this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_b' ), 'forgotten' ); $this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) ); $this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::A )[1] ); } public function test_transition_leaves_tables_without_the_columns_alone(): void { global $wpdb; $wpdb->last_error = ''; TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) ); $this->assertSame( '', (string) $wpdb->last_error ); } public function test_new_empty_table_gets_the_columns_at_once_but_a_populated_one_waits_for_the_cli(): void { global $wpdb; $wpdb->query( 'TRUNCATE TABLE `' . self::B . '`' ); TMDO_Post_Status_Sync::on_schema_updated( 'post', 'ps_b', self::B ); TMDO_Post_Status_Sync::on_schema_updated( 'post', 'ps_a', self::A ); $this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_b' ) ); $this->assertTrue( $this->has_index( self::B ) ); $this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_a' ) ); $this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) ); } /** * The migration engine (backfill, drift-check --fix) REPLACEs whole rows: * without the copies, a rebuilt row had NULL post_status, which turns the * status/date copy route off for the whole group (2.4 PR-127). */ public function test_a_row_rebuilt_from_native_keeps_its_copies(): void { global $wpdb; TMDO_Post_Status_Sync::run(); $GLOBALS['_wp_posts'][2] = (object) array( 'ID' => 2, 'post_type' => 'hp_listing', 'post_status' => 'draft', 'post_date' => '2026-02-01 10:00:00' ); $GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' ); $wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB" ); try { $wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (2, 'k', 'a2-native'), (3, 'k', 'a3-native')" ); WPDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 2 ); // Existing row. WPDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 3 ); // New row. } finally { $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" ); } $this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2] ); $this->assertSame( array( 'pending', '2026-03-01 10:00:00' ), $this->copies( self::A )[3] ); } public function test_flat_write_that_creates_a_row_fills_the_copies(): void { TMDO_Post_Status_Sync::run(); $mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); $mode->setAccessible( true ); // aeav_only: the pre-filter itself writes flat (dual_write syncs from native after the write). $mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) ); $GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' ); $GLOBALS['_wp_current_filter'] = 'update_post_metadata'; WPDO_Hook_Bus::intercept_update( null, 3, 'k', 'a3', '' ); WPDO_Hook_Bus::intercept_update( null, 1, 'k', 'a1b', '' ); $this->assertSame( array( 'pending', '2026-03-01 10:00:00' ), $this->copies( self::A )[3] ); $this->assertSame( array( 'publish', '2026-01-01 10:00:00' ), $this->copies( self::A )[1], 'existing row keeps its synced copies' ); } }