query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); $wpdb->query( 'CREATE TABLE `' . self::POSTS . '` ( ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, post_title text NOT NULL, post_type varchar(20) NOT NULL DEFAULT \'post\', post_status varchar(20) NOT NULL DEFAULT \'publish\', post_date datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, post_date_gmt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, post_modified datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, post_modified_gmt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, post_author bigint(20) unsigned NOT NULL DEFAULT 0, post_content longtext NOT NULL, post_excerpt text NOT NULL, comment_status varchar(20) NOT NULL DEFAULT \'open\', ping_status varchar(20) NOT NULL DEFAULT \'open\', post_password varchar(255) NOT NULL DEFAULT \'\', post_name varchar(200) NOT NULL DEFAULT \'\', to_ping text NOT NULL, pinged text NOT NULL, post_content_filtered longtext NOT NULL, post_parent bigint(20) unsigned NOT NULL DEFAULT 0, guid varchar(255) NOT NULL DEFAULT \'\', menu_order int(11) NOT NULL DEFAULT 0, post_mime_type varchar(100) NOT NULL DEFAULT \'\', comment_count bigint(20) NOT NULL DEFAULT 0, PRIMARY KEY (ID), KEY post_type (post_type), KEY post_title (post_title(64)) ) DEFAULT CHARACTER SET utf8mb4' ); $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); $wpdb->query( 'CREATE TABLE `' . self::POSTMETA . '` ( meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, post_id bigint(20) unsigned NOT NULL DEFAULT 0, meta_key varchar(255) DEFAULT NULL, meta_value longtext, PRIMARY KEY (meta_id), KEY post_id (post_id), KEY meta_key (meta_key(191)) ) DEFAULT CHARACTER SET utf8mb4' ); // wc_product flat target with all 19 columns. $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); $wpdb->query( 'CREATE TABLE `' . self::FLAT . '` ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, post_id bigint(20) unsigned NOT NULL, _price decimal(18,6) DEFAULT NULL, _regular_price decimal(18,6) DEFAULT NULL, _sale_price decimal(18,6) DEFAULT NULL, _stock bigint(20) DEFAULT NULL, _stock_status varchar(100) DEFAULT NULL, _sku varchar(255) DEFAULT NULL, _manage_stock varchar(100) DEFAULT NULL, _backorders varchar(100) DEFAULT NULL, _sold_individually varchar(100) DEFAULT NULL, _virtual varchar(100) DEFAULT NULL, _downloadable varchar(100) DEFAULT NULL, _tax_class varchar(255) DEFAULT NULL, _tax_status varchar(100) DEFAULT NULL, _download_limit bigint(20) DEFAULT NULL, _download_expiry bigint(20) DEFAULT NULL, _product_version varchar(255) DEFAULT NULL, _wc_average_rating decimal(18,6) DEFAULT NULL, _wc_review_count bigint(20) DEFAULT NULL, total_sales bigint(20) 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_post_id (post_id) ) DEFAULT CHARACTER SET utf8mb4' ); // Register post adapter + groups. WPDO_Entity_Registry::init(); WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); WPDO_Post_Fields::register_entity_fields(); } public static function tearDownAfterClass(): void { global $wpdb; $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); // Reset Mode_Manager + Entity_Registry to avoid leaking state. $ref = new ReflectionClass( WPDO_Mode_Manager::class ); $cache = $ref->getProperty( 'cache' ); $cache->setAccessible( true ); $cache->setValue( null, null ); WPDO_Entity_Registry::init(); } protected function setUp(): void { global $wpdb; $wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' ); $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' ); $wpdb->query( 'TRUNCATE TABLE `' . self::FLAT . '`' ); // Other classes create/drop sibling flat tables (e.g. wpdo_post_attachment): // a stale table_exists() cache would make diagnose() COUNT a dropped table. TMDO_Schema_Manager::flush_table_exists_cache(); } private static function class_for( string $rel ): ?string { $map = array( 'interface-entity-adapter.php' => 'WPDO_Entity_Adapter_Interface', 'class-tmdo-entity-registry.php' => 'WPDO_Entity_Registry', 'class-tmdo-mode-manager.php' => 'WPDO_Mode_Manager', 'class-tmdo-schema-manager.php' => 'WPDO_Schema_Manager', 'class-tmdo-adapter-post.php' => 'WPDO_Adapter_Post', 'class-tmdo-post-fields.php' => 'WPDO_Post_Fields', 'class-tmdo-post-migration.php' => 'WPDO_Post_Migration', 'class-tmdo-postmeta-cleaner.php' => 'WPDO_Postmeta_Cleaner', 'class-tmdo-post-stress-tester.php' => 'WPDO_Post_Stress_Tester', ); foreach ( $map as $needle => $cls ) { if ( str_contains( $rel, $needle ) ) { return $cls; } } return null; } // ── End-to-end lifecycle ────────────────────────────────────────────────── /** * Full lifecycle: stress create → cleanup garbage → backfill → diagnose * → stress cleanup → final ratio assertion. * * This test is the contract net for v2.9.x phases working together. */ public function test_full_post_entity_lifecycle(): void { global $wpdb; // Phase 1 (v2.9.4): seed 10 product posts via stress tester. $create_result = WPDO_Post_Stress_Tester::create( 'product', 10 ); $this->assertSame( 10, $create_result['created'] ); $this->assertSame( 10, WPDO_Post_Stress_Tester::count_test_posts() ); // Add some garbage to validate v2.9.0 cleanup phase. for ( $i = 0; $i < 5; $i++ ) { $wpdb->insert( self::POSTMETA, array( 'post_id' => 1, 'meta_key' => '_transient_test_' . $i, 'meta_value' => 'x', ) ); $wpdb->insert( self::POSTMETA, array( 'post_id' => 1, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01', ) ); } // Phase 2 (v2.9.0): cleanup garbage. $garbage_before = WPDO_Postmeta_Cleaner::count_garbage( 'all' ); $this->assertSame( 5, $garbage_before['transients'] ); $this->assertSame( 5, $garbage_before['wp_old_date'] ); $deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'all' ); $this->assertSame( 10, $deleted['total'] ); $garbage_after = WPDO_Postmeta_Cleaner::count_garbage( 'all' ); $this->assertSame( 0, $garbage_after['total'], 'After delete, all garbage gone.' ); // Phase 3 (v2.9.3): diagnose post entity state. $diag1 = WPDO_Post_Migration::diagnose(); $this->assertSame( 10, $diag1['posts'] ); $this->assertSame( 'disabled', $diag1['mode'] ); $this->assertGreaterThan( 0, $diag1['groups']['wc_product']['eav_rows'], 'wc_product seeded keys present.' ); $this->assertSame( 0, $diag1['groups']['wc_product']['flat_rows'], 'flat empty before backfill.' ); // Phase 4 (v2.9.3): backfill wc_product from postmeta to flat. $backfill = WPDO_Post_Migration::backfill_group( 'wc_product' ); $this->assertSame( 10, $backfill['migrated'], '10 products backfilled to flat.' ); // Phase 5: re-diagnose; flat_rows must equal post count for wc_product. $diag2 = WPDO_Post_Migration::diagnose(); $this->assertSame( 10, $diag2['groups']['wc_product']['flat_rows'] ); // Phase 6 (v2.9.4): stress cleanup removes everything. $cleanup = WPDO_Post_Stress_Tester::cleanup(); $this->assertSame( 10, $cleanup['deleted_posts'] ); $this->assertSame( 0, WPDO_Post_Stress_Tester::count_test_posts() ); // Final state: empty everywhere. $diag3 = WPDO_Post_Migration::diagnose(); $this->assertSame( 0, $diag3['posts'] ); $this->assertSame( 0, $diag3['groups']['wc_product']['eav_rows'] ); // Note: flat rows survive stress cleanup (ON DELETE CASCADE not configured // in test fixture); production v2.9.4 cleanup() also sweeps flat tables. $this->assertGreaterThanOrEqual( 0, $diag3['groups']['wc_product']['flat_rows'] ); } /** * Verifies that v2.9.0 cleanup + v2.9.3 backfill have zero overlap: * cleanup keys (_transient_*, _wp_old_date, stale _edit_lock) must not * collide with any v2.9.1 entity group's managed keys. */ public function test_cleanup_keys_never_overlap_managed_group_keys(): void { $managed = WPDO_Post_Migration::get_managed_keys(); foreach ( $managed as $key ) { $this->assertStringStartsNotWith( '_transient_', $key ); $this->assertStringStartsNotWith( '_transient_timeout_', $key ); $this->assertNotSame( '_wp_old_date', $key ); $this->assertNotSame( '_edit_lock', $key ); } } }