3e96357459
- add_flat_rows (wp_insert_post, after transition_post_status) inserts the copies with the empty row: a NULL copy turned the copy route off for the whole group until the next sync - wp tmdo post-status-sync bumps the routed count version (not on dry runs) - Test bootstrap: minimal WP_Post stub - Found in the 2.4 correctness review (residual risks) - Verify: 2 new integration tests; integration 606, unit 704, phpcs 0, stan OK - PR: PR-133
288 lines
14 KiB
PHP
288 lines
14 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* post_status / post_date copies on post entity tables (O-1 schema).
|
|
*
|
|
* Listing 1 (publish) has rows in both groups, draft 2 only in ps_a.
|
|
*/
|
|
class PostStatusSyncTest extends TestCase {
|
|
|
|
private const A = 'wp_itest_wpdo_post_ps_a';
|
|
private const B = 'wp_itest_wpdo_post_ps_b';
|
|
|
|
public static function setUpBeforeClass(): void {
|
|
$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-post.php',
|
|
'includes/engine/class-tmdo-hook-bus.php',
|
|
'includes/engine/class-tmdo-post-status-sync.php',
|
|
'includes/engine/class-tmdo-entity-migration-engine.php',
|
|
'includes/integrations/class-tmdo-post-fields.php',
|
|
'includes/query/class-tmdo-post-query-router.php',
|
|
) as $file ) {
|
|
require_once $base . $file;
|
|
}
|
|
}
|
|
|
|
protected function setUp(): void {
|
|
global $wpdb;
|
|
self::drop();
|
|
$wpdb->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<int, array{0:?string, 1:?string}> 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;
|
|
}
|
|
|
|
/** @return array<string, string[]> index name → columns */
|
|
private function indexes( string $table ): array {
|
|
global $wpdb;
|
|
$out = array();
|
|
foreach ( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ) as $row ) {
|
|
$out[ $row['Key_name'] ][ (int) $row['Seq_in_index'] - 1 ] = $row['Column_name'];
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
private function has_index( string $table ): bool {
|
|
return array( 'post_status', 'post_date', 'post_id' ) === ( $this->indexes( $table )['idx_post_status_date_id'] ?? null );
|
|
}
|
|
|
|
/**
|
|
* 2.4 PR-128: the date route's ID tie-break is read from post_id, which the
|
|
* index must end with; the 2.2 two-column index is replaced, not kept beside it.
|
|
*/
|
|
public function test_run_replaces_the_two_column_index_of_existing_tables(): void {
|
|
global $wpdb;
|
|
$wpdb->query( 'ALTER TABLE `' . self::A . '` ADD COLUMN post_status VARCHAR(20) COLLATE utf8mb4_unicode_520_ci DEFAULT NULL, ADD COLUMN post_date DATETIME DEFAULT NULL, ADD INDEX idx_post_status_date (post_status, post_date)' );
|
|
$GLOBALS['_wp_options'][ TMDO_Post_Status_Sync::OPTION ] = array( 'ps_a' => 'ready' );
|
|
|
|
TMDO_Post_Status_Sync::run();
|
|
|
|
$this->assertTrue( $this->has_index( self::A ) );
|
|
$this->assertArrayNotHasKey( 'idx_post_status_date', $this->indexes( self::A ) );
|
|
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) );
|
|
}
|
|
|
|
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'] );
|
|
}
|
|
|
|
/** Routed counts cached while copies were stale are dropped by a sync (2.4 correctness review). */
|
|
public function test_a_sync_invalidates_cached_routed_counts_but_a_dry_run_does_not(): void {
|
|
$GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before';
|
|
TMDO_Post_Status_Sync::run( true );
|
|
$this->assertSame( 'before', $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] );
|
|
|
|
TMDO_Post_Status_Sync::run();
|
|
$this->assertNotSame( 'before', $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] );
|
|
}
|
|
|
|
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] );
|
|
}
|
|
|
|
/**
|
|
* Backfills and drift-check --fix rebuild rows outside the Hook Bus: the
|
|
* routed counts cached for the old rows must go (2.4 PR-129; dev36: a
|
|
* vendor search kept counting 21515 of 21522 matches after a fix).
|
|
*/
|
|
public function test_rebuilding_post_rows_invalidates_cached_routed_counts(): void {
|
|
global $wpdb;
|
|
$status = $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status';
|
|
$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" );
|
|
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$status}` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" );
|
|
$version = static fn() => $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] ?? null;
|
|
try {
|
|
$wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (2, 'k', 'a2-native')" );
|
|
|
|
$GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before';
|
|
TMDO_Entity_Migration_Engine::migrate_group( 'post', 'ps_a', array( 'resume' => false, 'sleep_ms' => 0, 'dry_run' => true ) );
|
|
$this->assertSame( 'before', $version(), 'a dry run writes nothing' );
|
|
|
|
foreach ( array(
|
|
'migrate_entity' => static fn() => TMDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 2 ),
|
|
'migrate_group' => static fn() => TMDO_Entity_Migration_Engine::migrate_group( 'post', 'ps_a', array( 'resume' => false, 'sleep_ms' => 0 ) ),
|
|
'migrate_group_batch' => static fn() => TMDO_Entity_Migration_Engine::migrate_group_batch( 'post', 'ps_a' ),
|
|
) as $path => $run ) {
|
|
TMDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'ps_a' );
|
|
$GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before';
|
|
$run();
|
|
$this->assertNotSame( 'before', $version(), $path );
|
|
}
|
|
} finally {
|
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
|
|
$wpdb->query( "DROP TABLE IF EXISTS `{$status}`" );
|
|
}
|
|
}
|
|
|
|
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' );
|
|
}
|
|
}
|