chore: initial snapshot of 2meet-data-optimizer v0.1.0

Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Performance benchmark integration tests.
*
* Exercises Zone A (Hot), Zone B (Warm), and Zone C (Cold) with N=200
* write + read operations and verifies data integrity. Timing is captured
* and printed to STDOUT so it appears in --testdox output. No hard timing
* assertions are made — correctness assertions guard against regressions.
*
* Run alone: ./vendor/bin/phpunit --configuration phpunit-integration.xml
* --filter BenchmarkIntegrationTest --testdox
*/
class BenchmarkIntegrationTest extends TestCase {
private const N = 200; // Rows per benchmark zone.
private const POST_TYPE = 'bench';
/** Zone A table: wp_itest_wpdo_hot_bench */
private static string $hot_table;
/** Zone B table: wp_itest_wpdo_warm_bench (isolated from other warm tests) */
private static string $warm_table;
/** Zone C table: wp_itest_wpdo_cold_bench */
private static string $cold_table;
/** Collected timing results printed in tearDownAfterClass(). */
private static array $report = [];
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
self::$hot_table = $wpdb->prefix . 'wpdo_hot_bench';
self::$warm_table = $wpdb->prefix . 'wpdo_warm_bench';
self::$cold_table = $wpdb->prefix . 'wpdo_cold_bench';
// Zone A table.
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `" . self::$hot_table . "` (
post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
bench_val DECIMAL(10,2) DEFAULT NULL,
updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Zone B table (KV schema).
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `" . self::$warm_table . "` (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
meta_key VARCHAR(255) NOT NULL DEFAULT '',
meta_value LONGTEXT DEFAULT NULL,
expires_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY post_meta (post_id, meta_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Zone C table.
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `" . self::$cold_table . "` (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
data LONGTEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
UNIQUE KEY ui_post_id (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$hot_table . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$cold_table . "`" );
// Print timing summary.
fwrite( STDOUT, "\n\n ── Benchmark Results (N=" . self::N . " per zone) ──────────────────────\n" );
foreach ( self::$report as $label => $ms ) {
fwrite( STDOUT, sprintf( " %-40s %7.1f ms\n", $label, $ms ) );
}
fwrite( STDOUT, " ──────────────────────────────────────────────────────\n\n" );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( "TRUNCATE TABLE `" . self::$hot_table . "`" );
$wpdb->query( "TRUNCATE TABLE `" . self::$warm_table . "`" );
$wpdb->query( "TRUNCATE TABLE `" . self::$cold_table . "`" );
$GLOBALS['_wp_cache'] = [];
}
// ── Zone A (Hot) ─────────────────────────────────────────────────────────
public function test_zone_a_bulk_write_performance(): void {
global $wpdb;
$now = gmdate( 'Y-m-d H:i:s' );
$start = microtime( true );
for ( $i = 1; $i <= self::N; $i++ ) {
$wpdb->query(
$wpdb->prepare(
"INSERT INTO `" . self::$hot_table . "` (post_id, bench_val, updated_at)
VALUES (%d, %f, %s)
ON DUPLICATE KEY UPDATE bench_val = VALUES(bench_val), updated_at = VALUES(updated_at)",
$i,
$i * 10.0,
$now
)
);
}
$ms = ( microtime( true ) - $start ) * 1000;
self::$report['Zone A: ' . self::N . ' UPSERT writes'] = $ms;
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$hot_table . "`" );
$this->assertSame( self::N, $count, 'Zone A: all rows written' );
}
public function test_zone_a_bulk_read_performance(): void {
global $wpdb;
// Seed data.
$now = gmdate( 'Y-m-d H:i:s' );
for ( $i = 1; $i <= self::N; $i++ ) {
$wpdb->query( $wpdb->prepare(
"INSERT INTO `" . self::$hot_table . "` (post_id, bench_val, updated_at) VALUES (%d, %f, %s)",
$i, $i * 10.0, $now
) );
}
// Benchmark individual point-reads.
$start = microtime( true );
$values = [];
for ( $i = 1; $i <= self::N; $i++ ) {
$values[] = $wpdb->get_var(
$wpdb->prepare( "SELECT bench_val FROM `" . self::$hot_table . "` WHERE post_id = %d", $i )
);
}
$ms = ( microtime( true ) - $start ) * 1000;
self::$report['Zone A: ' . self::N . ' point reads'] = $ms;
$this->assertCount( self::N, $values, 'Zone A: all rows readable' );
$this->assertSame( '10.00', $values[0] ); // post_id=1 → 1*10=10
}
public function test_zone_a_filtered_query_performance(): void {
global $wpdb;
$now = gmdate( 'Y-m-d H:i:s' );
for ( $i = 1; $i <= self::N; $i++ ) {
$wpdb->query( $wpdb->prepare(
"INSERT INTO `" . self::$hot_table . "` (post_id, bench_val, updated_at) VALUES (%d, %f, %s)",
$i, $i * 10.0, $now
) );
}
// Filtered query: bench_val >= 1000 (100 rows).
$start = microtime( true );
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, bench_val FROM `" . self::$hot_table . "` WHERE bench_val >= %f ORDER BY bench_val ASC",
1000.0
),
ARRAY_A
);
$ms = ( microtime( true ) - $start ) * 1000;
self::$report['Zone A: filtered query (half dataset)'] = $ms;
$this->assertCount( 101, $rows, 'Zone A: filter returns correct row count' );
$this->assertSame( '1000.00', $rows[0]['bench_val'] );
}
// ── Zone B (Warm) ─────────────────────────────────────────────────────────
public function test_zone_b_bulk_write_performance(): void {
global $wpdb;
$now = gmdate( 'Y-m-d H:i:s' );
$start = microtime( true );
for ( $i = 1; $i <= self::N; $i++ ) {
$wpdb->query(
$wpdb->prepare(
"INSERT INTO `" . self::$warm_table . "`
(post_id, meta_key, meta_value, created_at)
VALUES (%d, %s, %s, %s)
ON DUPLICATE KEY UPDATE meta_value = VALUES(meta_value)",
$i, 'bench_views', (string) $i, $now
)
);
}
$ms = ( microtime( true ) - $start ) * 1000;
self::$report['Zone B: ' . self::N . ' KV writes'] = $ms;
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$warm_table . "`" );
$this->assertSame( self::N, $count, 'Zone B: all KV rows written' );
}
public function test_zone_b_bulk_read_performance(): void {
global $wpdb;
$now = gmdate( 'Y-m-d H:i:s' );
for ( $i = 1; $i <= self::N; $i++ ) {
$wpdb->query( $wpdb->prepare(
"INSERT INTO `" . self::$warm_table . "` (post_id, meta_key, meta_value, created_at) VALUES (%d, %s, %s, %s)",
$i, 'bench_views', (string) $i, $now
) );
}
$start = microtime( true );
$values = [];
for ( $i = 1; $i <= self::N; $i++ ) {
$values[] = $wpdb->get_var( $wpdb->prepare(
"SELECT meta_value FROM `" . self::$warm_table . "` WHERE post_id = %d AND meta_key = %s",
$i, 'bench_views'
) );
}
$ms = ( microtime( true ) - $start ) * 1000;
self::$report['Zone B: ' . self::N . ' KV reads'] = $ms;
$this->assertCount( self::N, $values, 'Zone B: all KV rows readable' );
$this->assertSame( '1', $values[0] ); // post_id=1 → value=1
}
// ── Zone C (Cold) ─────────────────────────────────────────────────────────
public function test_zone_c_bulk_write_performance(): void {
global $wpdb;
$now = gmdate( 'Y-m-d H:i:s' );
$start = microtime( true );
for ( $i = 1; $i <= self::N; $i++ ) {
$json = wp_json_encode( [
'hp_description' => 'Benchmark listing description number ' . $i,
'hp_website' => 'https://listing' . $i . '.example.com',
'hp_facebook' => 'https://facebook.com/listing' . $i,
] );
$wpdb->query(
$wpdb->prepare(
"INSERT INTO `" . self::$cold_table . "` (post_id, data, updated_at)
VALUES (%d, %s, %s)
ON DUPLICATE KEY UPDATE data = VALUES(data), updated_at = VALUES(updated_at)",
$i, $json, $now
)
);
}
$ms = ( microtime( true ) - $start ) * 1000;
self::$report['Zone C: ' . self::N . ' JSON blob writes'] = $ms;
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$cold_table . "`" );
$this->assertSame( self::N, $count, 'Zone C: all JSON rows written' );
}
public function test_zone_c_bulk_read_performance(): void {
global $wpdb;
$now = gmdate( 'Y-m-d H:i:s' );
for ( $i = 1; $i <= self::N; $i++ ) {
$json = wp_json_encode( [
'hp_description' => 'Description ' . $i,
'hp_website' => 'https://listing' . $i . '.example.com',
] );
$wpdb->query( $wpdb->prepare(
"INSERT INTO `" . self::$cold_table . "` (post_id, data, updated_at) VALUES (%d, %s, %s)",
$i, $json, $now
) );
}
$start = microtime( true );
$decoded = 0;
for ( $i = 1; $i <= self::N; $i++ ) {
$json = $wpdb->get_var( $wpdb->prepare(
"SELECT data FROM `" . self::$cold_table . "` WHERE post_id = %d",
$i
) );
$data = json_decode( (string) $json, true );
if ( is_array( $data ) && isset( $data['hp_description'] ) ) {
$decoded++;
}
}
$ms = ( microtime( true ) - $start ) * 1000;
self::$report['Zone C: ' . self::N . ' JSON blob reads'] = $ms;
$this->assertSame( self::N, $decoded, 'Zone C: all JSON blobs readable' );
}
}
@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Comment_Stress_Tester (v2.13.1).
*
* Verifies the comment stress tester contract:
* - State machine: start / get_state / get_progress / cancel
* - count_test_comments() matches email-domain marker
* - cleanup() removes test comments + cascade
* - Input validation throws / errors correctly
* - Run benchmark structure
*
* Note: Realistic mode tests (wp_insert_comment path) use bootstrap stub which
* inserts directly without firing filter/action chain.
*/
class CommentStressTesterTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const COMMENTS = 'wp_itest_comments';
private const COMMENTMETA = 'wp_itest_commentmeta';
private static int $test_post_id = 0;
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Comment_Stress_Tester' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-comment-stress-tester.php';
}
$wpdb->posts = self::POSTS;
$wpdb->comments = self::COMMENTS;
$wpdb->commentmeta = self::COMMENTMETA;
$wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::POSTS . '` (
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_title text NOT NULL DEFAULT "",
post_status varchar(20) NOT NULL DEFAULT "publish",
post_type varchar(20) NOT NULL DEFAULT "post",
comment_count bigint(20) NOT NULL DEFAULT 0,
PRIMARY KEY (ID)
) DEFAULT CHARACTER SET utf8mb4' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::COMMENTS . '` (
comment_ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
comment_post_ID bigint(20) unsigned NOT NULL DEFAULT 0,
comment_author tinytext NOT NULL,
comment_author_email varchar(100) NOT NULL DEFAULT "",
comment_author_url varchar(200) NOT NULL DEFAULT "",
comment_author_IP varchar(100) NOT NULL DEFAULT "",
comment_date datetime NOT NULL DEFAULT "1970-01-01 00:00:00",
comment_date_gmt datetime NOT NULL DEFAULT "1970-01-01 00:00:00",
comment_content text NOT NULL,
comment_karma int(11) NOT NULL DEFAULT 0,
comment_approved varchar(20) NOT NULL DEFAULT "1",
comment_agent varchar(255) NOT NULL DEFAULT "",
comment_type varchar(20) NOT NULL DEFAULT "comment",
comment_parent bigint(20) unsigned NOT NULL DEFAULT 0,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (comment_ID),
KEY comment_author_email (comment_author_email(10)),
KEY comment_post_ID (comment_post_ID)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::COMMENTMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
comment_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY comment_id (comment_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4' );
// Seed a single fixture post to satisfy post_exists() checks.
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' );
$wpdb->insert( self::POSTS, array(
'post_title' => 'WPDO Comment Stress Fixture Post',
'post_status' => 'publish',
'post_type' => 'post',
) );
self::$test_post_id = (int) $wpdb->insert_id;
}
public static function tearDownAfterClass(): void {
global $wpdb;
foreach ( array( self::COMMENTS, self::COMMENTMETA ) as $tbl ) {
$wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' );
}
// Don't drop wp_itest_posts — shared fixture across test classes.
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTS . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTMETA . '`' );
// Reset state per test so each starts idle.
unset( $GLOBALS['_wp_options'][ WPDO_Comment_Stress_Tester::OPT_STATE ] );
unset( $GLOBALS['_wp_transients'][ WPDO_Comment_Stress_Tester::CANCEL_FLAG ] );
unset( $GLOBALS['_wp_transients']['wpdo_comment_stress_pump_lock'] );
}
// ── create() (fast-path direct SQL) ──────────────────────────────────────
public function test_create_inserts_comments_for_post(): void {
$result = WPDO_Comment_Stress_Tester::create( self::$test_post_id, 5 );
$this->assertSame( 5, $result['created'] );
$this->assertSame( self::$test_post_id, $result['post_id'] );
$this->assertNotNull( $result['first_id'] );
$this->assertNotNull( $result['last_id'] );
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTS . '`' );
$this->assertSame( 5, $count );
}
public function test_create_uses_stress_email_domain(): void {
WPDO_Comment_Stress_Tester::create( self::$test_post_id, 3 );
global $wpdb;
$prefix_count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `" . self::COMMENTS . "` WHERE comment_author_email LIKE %s",
'%@' . WPDO_Comment_Stress_Tester::TEST_EMAIL_DOMAIN
)
);
$this->assertSame( 3, $prefix_count );
}
public function test_create_seeds_commentmeta_keys(): void {
WPDO_Comment_Stress_Tester::create( self::$test_post_id, 3 );
global $wpdb;
$total_meta = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTMETA . '`' );
// 3 comments × 1 key (hp_rating) = 3.
$this->assertSame( 3, $total_meta );
}
public function test_create_rejects_bad_post_id(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Comment_Stress_Tester::create( 0, 3 );
}
public function test_create_rejects_zero_count(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Comment_Stress_Tester::create( self::$test_post_id, 0 );
}
public function test_create_rejects_excessive_count(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Comment_Stress_Tester::create( self::$test_post_id, 100001 );
}
// ── count_test_comments() ────────────────────────────────────────────────
public function test_count_test_comments_returns_zero_for_empty(): void {
$this->assertSame( 0, WPDO_Comment_Stress_Tester::count_test_comments() );
}
public function test_count_test_comments_counts_only_stress_emails(): void {
WPDO_Comment_Stress_Tester::create( self::$test_post_id, 4 );
global $wpdb;
$wpdb->insert( self::COMMENTS, array(
'comment_post_ID' => self::$test_post_id,
'comment_author' => 'Real',
'comment_author_email' => 'real@example.com',
'comment_content' => 'Real comment',
'comment_approved' => '1',
) );
$this->assertSame( 4, WPDO_Comment_Stress_Tester::count_test_comments() );
}
// ── cleanup() ─────────────────────────────────────────────────────────────
public function test_cleanup_removes_test_comments_and_cascade(): void {
WPDO_Comment_Stress_Tester::create( self::$test_post_id, 5 );
$this->assertSame( 5, WPDO_Comment_Stress_Tester::count_test_comments() );
$result = WPDO_Comment_Stress_Tester::cleanup();
$this->assertSame( 5, $result['deleted_comments'] );
global $wpdb;
$this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTS . '`' ) );
$this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTMETA . '`' ) );
}
public function test_cleanup_preserves_non_stress_comments(): void {
global $wpdb;
$wpdb->insert( self::COMMENTS, array(
'comment_post_ID' => self::$test_post_id,
'comment_author' => 'Real',
'comment_author_email' => 'real@example.com',
'comment_content' => 'Real comment',
'comment_approved' => '1',
) );
WPDO_Comment_Stress_Tester::create( self::$test_post_id, 3 );
$result = WPDO_Comment_Stress_Tester::cleanup();
$this->assertSame( 3, $result['deleted_comments'] );
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTS . '`' );
$this->assertSame( 1, $remaining );
}
public function test_cleanup_idempotent_on_empty(): void {
$first = WPDO_Comment_Stress_Tester::cleanup();
$second = WPDO_Comment_Stress_Tester::cleanup();
$this->assertSame( 0, $first['deleted_comments'] );
$this->assertSame( 0, $second['deleted_comments'] );
}
// ── State machine ────────────────────────────────────────────────────────
public function test_get_state_returns_empty_when_idle(): void {
$this->assertSame( array(), WPDO_Comment_Stress_Tester::get_state() );
}
public function test_get_progress_returns_idle_when_no_state(): void {
$progress = WPDO_Comment_Stress_Tester::get_progress( false );
$this->assertSame( 'idle', $progress['status'] );
}
public function test_start_persists_state_with_running_status(): void {
$result = WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10, 'fast', 5 );
$this->assertTrue( $result['ok'], 'start should succeed' );
$state = $result['state'];
$this->assertSame( 'running', $state['status'] );
$this->assertSame( self::$test_post_id, $state['post_id'] );
$this->assertSame( 'fast', $state['mode'] );
$this->assertSame( 10, $state['target'] );
$this->assertSame( 5, $state['batch_size'] );
}
public function test_start_rejects_unknown_post(): void {
$result = WPDO_Comment_Stress_Tester::start( 999999, 10 );
$this->assertFalse( $result['ok'] );
$this->assertStringContainsString( 'unknown_post', $result['error'] );
}
public function test_start_rejects_invalid_mode(): void {
$result = WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10, 'turbo' );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'invalid mode', $result['error'] );
}
public function test_start_rejects_concurrent_run(): void {
WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10 );
$result = WPDO_Comment_Stress_Tester::start( self::$test_post_id, 5 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'already_running', $result['error'] );
}
public function test_run_batch_advances_processed_count(): void {
WPDO_Comment_Stress_Tester::start( self::$test_post_id, 6, 'fast', 3 );
WPDO_Comment_Stress_Tester::run_batch();
$progress = WPDO_Comment_Stress_Tester::get_progress( false );
$this->assertSame( 3, $progress['processed'] );
$this->assertSame( 1, $progress['batches_done'] );
$this->assertSame( 'running', $progress['status'] );
WPDO_Comment_Stress_Tester::run_batch();
$progress = WPDO_Comment_Stress_Tester::get_progress( false );
$this->assertSame( 6, $progress['processed'] );
$this->assertSame( 'completed', $progress['status'] );
}
public function test_cancel_marks_state_as_cancelled(): void {
WPDO_Comment_Stress_Tester::start( self::$test_post_id, 100, 'fast', 50 );
$result = WPDO_Comment_Stress_Tester::cancel();
$this->assertTrue( $result['ok'] );
$this->assertSame( 'cancelled', $result['state']['status'] );
// In-flight batch run after cancel must NOT bump status back to running.
WPDO_Comment_Stress_Tester::run_batch();
$state = WPDO_Comment_Stress_Tester::get_state();
$this->assertSame( 'cancelled', $state['status'] );
}
public function test_cancel_returns_no_active_job_when_idle(): void {
$result = WPDO_Comment_Stress_Tester::cancel();
$this->assertTrue( $result['ok'] );
$this->assertSame( 'no_active_job', $result['message'] ?? '' );
}
public function test_get_progress_includes_pct_and_eta_keys(): void {
WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10, 'fast', 5 );
WPDO_Comment_Stress_Tester::run_batch();
$progress = WPDO_Comment_Stress_Tester::get_progress( false );
$this->assertArrayHasKey( 'pct', $progress );
$this->assertArrayHasKey( 'rate_per_sec', $progress );
$this->assertArrayHasKey( 'elapsed_sec', $progress );
$this->assertArrayHasKey( 'eta_sec', $progress );
$this->assertArrayHasKey( 'test_comment_count', $progress );
$this->assertSame( 50.0, $progress['pct'] );
}
public function test_run_benchmark_returns_structured_payload(): void {
WPDO_Comment_Stress_Tester::start( self::$test_post_id, 4, 'fast', 4 );
WPDO_Comment_Stress_Tester::run_batch();
$state = WPDO_Comment_Stress_Tester::get_state();
$this->assertSame( 'completed', $state['status'] );
$this->assertIsArray( $state['benchmark'] );
$this->assertArrayHasKey( 'write', $state['benchmark'] );
$this->assertArrayHasKey( 'db_sizes', $state['benchmark'] );
$this->assertSame( self::$test_post_id, $state['benchmark']['post_id'] );
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Commentmeta_Cleaner — wp_commentmeta garbage cleanup (v2.12.0).
*
* Verifies count_garbage() and delete_garbage() against a real MariaDB test table:
* - target=wxr_import → meta_key LIKE '_wxr_import_%'
* - target=demo_data → meta_key LIKE '_2meet_demo_%'
* - target=transients → meta_key LIKE '_transient_%' OR LIKE '_transient_timeout_%'
* - target=orphan_post_meta → meta_key IN known orphan post-domain keys
* - target=all → union of all four
*/
class CommentmetaCleanerIntegrationTest extends TestCase {
private const COMMENTMETA = 'wp_itest_commentmeta';
public static function setUpBeforeClass(): void {
global $wpdb;
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-commentmeta-cleaner.php';
$wpdb->commentmeta = self::COMMENTMETA;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTMETA . '`' );
$wpdb->query(
'CREATE TABLE `' . self::COMMENTMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
comment_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY comment_id (comment_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTMETA . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTMETA . '`' );
}
private function seed( array $rows ): void {
global $wpdb;
foreach ( $rows as $row ) {
$wpdb->insert( self::COMMENTMETA, $row );
}
}
// ── count_garbage ────────────────────────────────────────────────────────
public function test_count_garbage_returns_zero_for_empty_table(): void {
$counts = WPDO_Commentmeta_Cleaner::count_garbage( 'all' );
$this->assertSame( 0, $counts['wxr_import'] );
$this->assertSame( 0, $counts['demo_data'] );
$this->assertSame( 0, $counts['transients'] );
$this->assertSame( 0, $counts['orphan_post_meta'] );
$this->assertSame( 0, $counts['total'] );
}
public function test_count_garbage_counts_wxr_import(): void {
$this->seed( array(
array( 'comment_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
array( 'comment_id' => 2, 'meta_key' => '_wxr_import_post', 'meta_value' => 'b' ),
array( 'comment_id' => 3, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
) );
$counts = WPDO_Commentmeta_Cleaner::count_garbage( 'wxr_import' );
$this->assertSame( 2, $counts['wxr_import'] );
$this->assertSame( 2, $counts['total'] );
}
public function test_count_garbage_counts_orphan_post_meta(): void {
$this->seed( array(
array( 'comment_id' => 1, 'meta_key' => '_hp_price', 'meta_value' => '99' ),
array( 'comment_id' => 1, 'meta_key' => '_hp_status', 'meta_value' => 'publish' ),
array( 'comment_id' => 2, 'meta_key' => '_thumbnail_id', 'meta_value' => '50' ),
array( 'comment_id' => 3, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
array( 'comment_id' => 4, 'meta_key' => 'note_group', 'meta_value' => 'foo' ),
) );
$counts = WPDO_Commentmeta_Cleaner::count_garbage( 'orphan_post_meta' );
$this->assertSame( 3, $counts['orphan_post_meta'] );
$this->assertSame( 3, $counts['total'] );
}
public function test_count_garbage_all_unions_four_buckets(): void {
$this->seed( array(
array( 'comment_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
array( 'comment_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
array( 'comment_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
array( 'comment_id' => 4, 'meta_key' => '_hp_price', 'meta_value' => '99' ),
array( 'comment_id' => 5, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
) );
$counts = WPDO_Commentmeta_Cleaner::count_garbage( 'all' );
$this->assertSame( 1, $counts['wxr_import'] );
$this->assertSame( 1, $counts['demo_data'] );
$this->assertSame( 1, $counts['transients'] );
$this->assertSame( 1, $counts['orphan_post_meta'] );
$this->assertSame( 4, $counts['total'] );
}
// ── delete_garbage ────────────────────────────────────────────────────────
public function test_delete_garbage_removes_targeted_rows_only(): void {
$this->seed( array(
array( 'comment_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
array( 'comment_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
array( 'comment_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
array( 'comment_id' => 4, 'meta_key' => '_hp_price', 'meta_value' => '99' ),
array( 'comment_id' => 5, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
array( 'comment_id' => 6, 'meta_key' => 'note_group', 'meta_value' => 'foo' ),
) );
$deleted = WPDO_Commentmeta_Cleaner::delete_garbage( 'all' );
$this->assertSame( 1, $deleted['wxr_import'] );
$this->assertSame( 1, $deleted['demo_data'] );
$this->assertSame( 1, $deleted['transients'] );
$this->assertSame( 1, $deleted['orphan_post_meta'] );
$this->assertSame( 4, $deleted['total'] );
global $wpdb;
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTMETA . '`' );
$this->assertSame( 2, $remaining, 'hp_rating + note_group must survive' );
}
public function test_delete_garbage_orphan_post_meta_specific(): void {
$this->seed( array(
array( 'comment_id' => 1, 'meta_key' => '_hp_price', 'meta_value' => '99' ),
array( 'comment_id' => 2, 'meta_key' => '_hp_featured', 'meta_value' => '1' ),
array( 'comment_id' => 3, 'meta_key' => '_edit_lock', 'meta_value' => '111:1' ),
array( 'comment_id' => 4, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
array( 'comment_id' => 5, 'meta_key' => 'note_group', 'meta_value' => 'foo' ),
) );
$deleted = WPDO_Commentmeta_Cleaner::delete_garbage( 'orphan_post_meta' );
$this->assertSame( 3, $deleted['orphan_post_meta'] );
$this->assertSame( 3, $deleted['total'] );
global $wpdb;
$keys = $wpdb->get_col( 'SELECT meta_key FROM `' . self::COMMENTMETA . '` ORDER BY meta_key' );
$this->assertSame( array( 'hp_rating', 'note_group' ), $keys );
}
public function test_delete_garbage_idempotent_on_clean_table(): void {
$this->seed( array(
array( 'comment_id' => 1, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
) );
$first = WPDO_Commentmeta_Cleaner::delete_garbage( 'all' );
$second = WPDO_Commentmeta_Cleaner::delete_garbage( 'all' );
$this->assertSame( 0, $first['total'] );
$this->assertSame( 0, $second['total'] );
}
public function test_invalid_target_throws(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Commentmeta_Cleaner::count_garbage( 'bogus' );
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Crypto::migrate_v1_to_v2() (v2.15.0).
*
* Verifies the bulk migration path against a real wp_itest_options table:
* - Mixed format input (v1 / v2 / plaintext / empty) all classified correctly
* - Counts returned accurately
* - Idempotency: second run is no-op (all v2)
* - Non-wpdo prefix excluded from sweep
*/
class CryptoMigrationTest extends TestCase {
private const TEST_PREFIX = 'wp_itest_';
public static function setUpBeforeClass(): void {
global $wpdb;
$wpdb->prefix = self::TEST_PREFIX;
$wpdb->options = self::TEST_PREFIX . 'options';
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::TEST_PREFIX . 'options` (
option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
option_name varchar(191) NOT NULL DEFAULT "",
option_value longtext NOT NULL,
autoload varchar(20) NOT NULL DEFAULT "yes",
PRIMARY KEY (option_id),
UNIQUE KEY option_name (option_name)
) DEFAULT CHARACTER SET utf8mb4'
);
// Define WP auth constants for stable key derivation.
if ( ! defined( 'AUTH_KEY' ) ) {
define( 'AUTH_KEY', 'integration_auth_key_long_enough_xxxxxxxxxxxxxxxxxxxxxx' );
}
if ( ! defined( 'SECURE_AUTH_SALT' ) ) {
define( 'SECURE_AUTH_SALT', 'integration_secure_auth_salt_long_xxxxxxxxxxxxxxxxxxxx' );
}
}
protected function setUp(): void {
global $wpdb;
// Clear all wpdo_* options before each test for isolation.
$wpdb->query( "DELETE FROM `" . self::TEST_PREFIX . "options` WHERE option_name LIKE 'wpdo_%' OR option_name LIKE 'unrelated_%'" );
}
public function test_migrate_mixed_format_inputs(): void {
// Set up: 2 v1 blobs, 1 v2 blob, 1 plaintext, 1 unrelated (non-wpdo).
$plain1 = 'https://hooks.slack.com/services/legacy1';
$plain2 = 'https://discord.com/api/webhooks/legacy2';
$this->insert_v1_option( 'wpdo_legacy_slack', $plain1 );
$this->insert_v1_option( 'wpdo_legacy_discord', $plain2 );
// Already v2.
$this->set_option_raw( 'wpdo_already_v2', WPDO_Crypto::encrypt( 'already encrypted' ) );
// Plaintext.
$this->set_option_raw( 'wpdo_plaintext_secret', 'just text' );
// Unrelated prefix — must NOT be touched.
$this->set_option_raw( 'unrelated_secret', 'should be ignored' );
$counts = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' );
// Scanned 4 wpdo_* options (unrelated_ excluded).
$this->assertSame( 4, $counts['scanned'] );
$this->assertSame( 2, $counts['migrated'] );
$this->assertSame( 1, $counts['already_v2'] );
$this->assertSame( 1, $counts['plaintext'] );
$this->assertSame( 0, $counts['failed'] );
// Verify v1 blobs were upgraded to v2 and decrypt correctly.
$this->assertSame( 'v2', WPDO_Crypto::format_version( 'wpdo_legacy_slack' ) );
$this->assertSame( 'v2', WPDO_Crypto::format_version( 'wpdo_legacy_discord' ) );
$this->assertSame( $plain1, WPDO_Crypto::get_option( 'wpdo_legacy_slack' ) );
$this->assertSame( $plain2, WPDO_Crypto::get_option( 'wpdo_legacy_discord' ) );
// Plaintext untouched.
$this->assertSame( 'plaintext', WPDO_Crypto::format_version( 'wpdo_plaintext_secret' ) );
// Unrelated option untouched.
global $wpdb;
$unrelated_value = $wpdb->get_var(
$wpdb->prepare(
"SELECT option_value FROM `" . self::TEST_PREFIX . "options` WHERE option_name = %s",
'unrelated_secret'
)
);
$this->assertSame( 'should be ignored', $unrelated_value );
}
public function test_migrate_idempotent_second_run_is_noop(): void {
$plain = 'a value';
$this->insert_v1_option( 'wpdo_test_idempotent', $plain );
$first = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' );
$second = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' );
// First run migrates 1, second run sees it as already_v2.
$this->assertSame( 1, $first['migrated'] );
$this->assertSame( 0, $second['migrated'] );
$this->assertSame( 1, $second['already_v2'] );
// Value still decrypts correctly after both runs.
$this->assertSame( $plain, WPDO_Crypto::get_option( 'wpdo_test_idempotent' ) );
}
public function test_migrate_empty_set(): void {
$counts = WPDO_Crypto::migrate_v1_to_v2( 'nonexistent_prefix_' );
$this->assertSame( 0, $counts['scanned'] );
$this->assertSame( 0, $counts['migrated'] );
$this->assertSame( 0, $counts['failed'] );
}
public function test_migrate_preserves_value_semantics(): void {
// Realistic test: write a webhook-shaped string that includes URL chars
// + special padding to make sure no encoding artifacts surface.
$plain = 'https://hooks.slack.com/services/T01/B02/=+&%/special?chars=true';
$this->insert_v1_option( 'wpdo_realistic_webhook', $plain );
WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' );
$this->assertSame( $plain, WPDO_Crypto::get_option( 'wpdo_realistic_webhook' ) );
}
// ── Helpers ──────────────────────────────────────────────────────────────
/**
* Insert an option containing a hand-crafted v1 (CBC) ciphertext.
*/
private function insert_v1_option( string $name, string $plaintext ): void {
$key = substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', AUTH_KEY . SECURE_AUTH_SALT, true ), 0, 32 );
$iv = random_bytes( 16 );
$ct = openssl_encrypt( $plaintext, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
$blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
$this->set_option_raw( $name, $blob );
}
/**
* Write a raw option value directly (bypasses WPDO_Crypto::set_option).
*/
private function set_option_raw( string $name, string $value ): void {
global $wpdb;
$wpdb->query(
$wpdb->prepare(
'REPLACE INTO `' . self::TEST_PREFIX . 'options` (option_name, option_value, autoload) VALUES (%s, %s, %s)',
$name,
$value,
'no'
)
);
$GLOBALS['_wp_options'][ $name ] = $value;
}
}
+268
View File
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* End-to-end test for WPDO_Demo_Entity_Counter — proves the entity adapter
* framework is not just a stub but actually delivers the full lifecycle:
*
* 1. Schema install (custom table + composite UNIQUE + secondary index)
* 2. Dual-write via WPDO_DB::upsert (1 RT)
* 3. Read routing via Feature_Flags FSM (idle → cutover → cleanup → complete)
* 4. Top-N query (the killer use case postmeta cannot do efficiently)
*
* Tests user / term / comment entities to validate cross-entity coverage.
*
* @covers WPDO_Demo_Entity_Counter
*/
class DemoEntityCounterTest extends TestCase {
public static function setUpBeforeClass(): void {
WPDO_Demo_Entity_Counter::drop_table();
WPDO_Demo_Entity_Counter::install_table();
}
public static function tearDownAfterClass(): void {
WPDO_Demo_Entity_Counter::drop_table();
WPDO_Feature_Flags::reset( WPDO_Demo_Entity_Counter::MODULE );
}
protected function setUp(): void {
// Reset feature flag module to idle before each test.
WPDO_Feature_Flags::reset( WPDO_Demo_Entity_Counter::MODULE );
// Truncate the table for clean state.
global $wpdb;
$wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "`" );
// Reset native usermeta global stubs (when running under integration env).
$GLOBALS['_wp_usermeta'] = array();
}
// ── Schema ─────────────────────────────────────────────────────────────
public function test_install_table_creates_with_composite_unique(): void {
global $wpdb;
$table = $wpdb->prefix . WPDO_Demo_Entity_Counter::TABLE;
// Index check: ui_entity_counter must be UNIQUE on (entity_type, entity_id, counter_key).
$rows = $wpdb->get_results(
$wpdb->prepare(
'SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s ORDER BY INDEX_NAME, SEQ_IN_INDEX',
$table
),
ARRAY_A
);
$ui_cols = array();
foreach ( $rows as $r ) {
if ( 'ui_entity_counter' === $r['INDEX_NAME'] && '0' === (string) $r['NON_UNIQUE'] ) {
$ui_cols[] = $r['COLUMN_NAME'];
}
}
$this->assertSame( array( 'entity_type', 'entity_id', 'counter_key' ), $ui_cols );
}
// ── set() / get() — idle state (native fallback only) ──────────────────
public function test_set_writes_to_native_meta_in_idle_state(): void {
WPDO_Demo_Entity_Counter::set( 'user', 100, 'points', 50 );
$this->assertSame( 50, (int) get_user_meta( 100, 'points', true ) );
}
public function test_get_reads_native_in_idle_state(): void {
update_user_meta( 200, 'points', 75 );
$this->assertSame( 75, WPDO_Demo_Entity_Counter::get( 'user', 200, 'points' ) );
}
public function test_idle_state_does_not_dual_write(): void {
WPDO_Demo_Entity_Counter::set( 'user', 300, 'points', 99 );
global $wpdb;
$count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d",
'user',
300
)
);
$this->assertSame( 0, $count, 'idle state must NOT dual-write to demo table' );
}
// ── set() — dual_write state ────────────────────────────────────────────
public function test_dual_write_state_writes_to_both(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'user', 400, 'points', 123 );
global $wpdb;
$zone_value = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
'user',
400,
'points'
)
);
$this->assertSame( 123, $zone_value, 'dual_write must populate the zone table' );
$this->assertSame( 123, (int) get_user_meta( 400, 'points', true ), 'dual_write must also keep native meta' );
}
public function test_upsert_uses_single_round_trip(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
// Two rapid writes to the same key — should produce exactly 1 row, not 2.
WPDO_Demo_Entity_Counter::set( 'user', 500, 'points', 10 );
WPDO_Demo_Entity_Counter::set( 'user', 500, 'points', 25 );
global $wpdb;
$rows = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d",
'user',
500
)
);
$this->assertSame( '1', (string) $rows, 'composite UNIQUE must collapse to 1 row' );
$value = $wpdb->get_var(
$wpdb->prepare(
"SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
'user',
500,
'points'
)
);
$this->assertSame( '25', (string) $value, 'second write must overwrite via UPSERT' );
}
// ── get() — cutover state (read from zone) ─────────────────────────────
public function test_cutover_state_reads_from_zone_table(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'user', 600, 'points', 999 );
// Switch to cutover — reads now come from zone.
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'cutover' );
// Tamper with native meta to prove zone table is the source of truth.
update_user_meta( 600, 'points', 0 );
$this->assertSame( 999, WPDO_Demo_Entity_Counter::get( 'user', 600, 'points' ) );
}
public function test_cutover_falls_back_to_native_when_zone_row_missing(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'cutover' );
// No dual_write history — zone table is empty for this entity.
update_user_meta( 700, 'points', 42 );
$this->assertSame( 42, WPDO_Demo_Entity_Counter::get( 'user', 700, 'points' ), 'graceful fallback when zone row absent' );
}
// ── Cross-entity coverage ──────────────────────────────────────────────
public function test_term_entity_works(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'term', 800, 'usage_count', 17 );
global $wpdb;
$value = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
'term',
800,
'usage_count'
)
);
$this->assertSame( 17, $value );
}
public function test_comment_entity_works(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'comment', 900, 'helpful_count', 8 );
global $wpdb;
$value = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
'comment',
900,
'helpful_count'
)
);
$this->assertSame( 8, $value );
}
public function test_invalid_entity_type_returns_false(): void {
$this->assertFalse( WPDO_Demo_Entity_Counter::set( 'bogus', 1, 'k', 1 ) );
$this->assertSame( 0, WPDO_Demo_Entity_Counter::get( 'bogus', 1, 'k' ) );
}
// ── Top-N query (killer use case postmeta can't do efficiently) ───────
public function test_top_n_query_returns_sorted_results(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
// Seed 5 users with varying point counts.
WPDO_Demo_Entity_Counter::set( 'user', 1001, 'points', 100 );
WPDO_Demo_Entity_Counter::set( 'user', 1002, 'points', 500 );
WPDO_Demo_Entity_Counter::set( 'user', 1003, 'points', 200 );
WPDO_Demo_Entity_Counter::set( 'user', 1004, 'points', 800 );
WPDO_Demo_Entity_Counter::set( 'user', 1005, 'points', 350 );
$top3 = WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 3 );
$this->assertCount( 3, $top3 );
// Sorted DESC: 1004(800) > 1002(500) > 1005(350) > 1003(200) > 1001(100)
$this->assertSame( 1004, $top3[0]['entity_id'] );
$this->assertSame( 800, $top3[0]['counter_value'] );
$this->assertSame( 1002, $top3[1]['entity_id'] );
$this->assertSame( 500, $top3[1]['counter_value'] );
$this->assertSame( 1005, $top3[2]['entity_id'] );
$this->assertSame( 350, $top3[2]['counter_value'] );
}
public function test_top_n_filters_by_entity_type(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'user', 2001, 'points', 999 );
WPDO_Demo_Entity_Counter::set( 'term', 2001, 'usage_count', 999 ); // same id, different type.
$users = WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 10 );
$terms = WPDO_Demo_Entity_Counter::top_n( 'term', 'usage_count', 10 );
$this->assertCount( 1, $users );
$this->assertCount( 1, $terms );
$this->assertSame( 2001, $users[0]['entity_id'] );
$this->assertSame( 2001, $terms[0]['entity_id'] );
}
// ── Mini benchmark — proves zone table beats postmeta on top-N ────────
public function test_benchmark_top_n_zone_vs_postmeta_simulated(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
// Seed 100 users with random point values.
for ( $i = 3001; $i <= 3100; $i++ ) {
WPDO_Demo_Entity_Counter::set( 'user', $i, 'points', wp_rand( 0, 10000 ) );
}
// Time the zone-table top-10 query.
$t1 = microtime( true );
for ( $i = 0; $i < 100; $i++ ) {
WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 10 );
}
$zone_ms = ( microtime( true ) - $t1 ) * 1000;
// We expect 100 zone reads under 200ms total (well under "1 LEFT JOIN per request").
$this->assertLessThan(
500,
$zone_ms,
"100 top-N reads from zone table took {$zone_ms}ms — exceeded 500ms ceiling"
);
// Print for visibility (PHPUnit captures to test output, no assertion impact).
fwrite( STDOUT, "\n Demo benchmark: 100x top-10 in {$zone_ms}ms (avg " . round( $zone_ms / 100, 2 ) . "ms/call)\n" );
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Installer multisite cleanup (v2.14.0).
*
* Verifies the new `drop_all_tables_for_current_blog()` shared helper used
* by both `uninstall.php` and the `wp_uninitialize_site` hook handler.
*/
class InstallerCleanupTest extends TestCase {
// v2.14.0: dedicated prefix to avoid colliding with shared `wp_itest_*`
// fixtures created by other test classes. The cleanup helper uses
// `$wpdb->prefix` so changing the prefix scopes drops to our tables only.
private const TEST_PREFIX = 'wp_clnup_';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Installer' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-installer.php';
}
$wpdb->prefix = self::TEST_PREFIX;
$wpdb->options = self::TEST_PREFIX . 'options';
// Ensure options table exists (needed for delete_option fallback path).
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::TEST_PREFIX . 'options` (
option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
option_name varchar(191) NOT NULL DEFAULT "",
option_value longtext NOT NULL,
autoload varchar(20) NOT NULL DEFAULT "yes",
PRIMARY KEY (option_id),
UNIQUE KEY option_name (option_name)
) DEFAULT CHARACTER SET utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
// Drop the dedicated options table + any residual prefix tables.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TEST_PREFIX . 'options`' );
foreach ( array(
self::TEST_PREFIX . 'wpdo_archive',
self::TEST_PREFIX . 'wpdo_warm',
self::TEST_PREFIX . 'wpdo_errors',
self::TEST_PREFIX . 'wpdo_hot_test_type',
self::TEST_PREFIX . 'wpdo_user_profile',
self::TEST_PREFIX . 'wpdo_post_attachment',
self::TEST_PREFIX . 'wpdo_term_hp_taxonomy',
self::TEST_PREFIX . 'wpdo_comment_hp_review',
self::TEST_PREFIX . 'unrelated_table',
) as $tbl ) {
$wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' );
}
// Restore the shared integration test prefix so any teardown elsewhere
// that depends on `$wpdb->prefix === 'wp_itest_'` still works.
$wpdb->prefix = 'wp_itest_';
}
protected function setUp(): void {
global $wpdb;
// Reset options table state.
$wpdb->query( 'TRUNCATE TABLE `' . self::TEST_PREFIX . 'options`' );
$GLOBALS['_wp_options'] = array();
}
public function test_drops_static_tables(): void {
global $wpdb;
// Create a few WPDO-prefixed tables that should be dropped.
$wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_archive` (id INT)' );
$wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_warm` (id INT)' );
$counts = WPDO_Installer::drop_all_tables_for_current_blog();
$this->assertGreaterThanOrEqual( 2, $counts['tables_dropped'] );
$exists = (int) $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
self::TEST_PREFIX . 'wpdo_archive'
)
);
$this->assertSame( 0, $exists, 'wpdo_archive should be dropped' );
}
public function test_drops_dynamic_zone_tables(): void {
global $wpdb;
// Dynamic hot/cold zone tables should be discovered via LIKE pattern.
$wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_hot_test_type` (id INT)' );
$wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_user_profile` (id INT)' );
$wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_post_attachment` (id INT)' );
$wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_term_hp_taxonomy` (id INT)' );
$wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_comment_hp_review` (id INT)' );
WPDO_Installer::drop_all_tables_for_current_blog();
foreach ( array(
'wpdo_hot_test_type',
'wpdo_user_profile',
'wpdo_post_attachment',
'wpdo_term_hp_taxonomy',
'wpdo_comment_hp_review',
) as $tbl_suffix ) {
$exists = (int) $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
self::TEST_PREFIX . $tbl_suffix
)
);
$this->assertSame( 0, $exists, $tbl_suffix . ' should be dropped' );
}
}
public function test_does_not_drop_unrelated_tables(): void {
global $wpdb;
// Defensive: a table named like wpdo_X should be dropped, but a table
// with a non-wpdo prefix MUST NEVER be dropped even if name pattern
// would match.
$unrelated = self::TEST_PREFIX . 'unrelated_table';
$wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . $unrelated . '` (id INT)' );
WPDO_Installer::drop_all_tables_for_current_blog();
$exists = (int) $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$unrelated
)
);
$this->assertSame( 1, $exists, 'Non-wpdo table must not be dropped' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . $unrelated . '`' );
}
public function test_returns_counts_structure(): void {
$counts = WPDO_Installer::drop_all_tables_for_current_blog();
$this->assertIsArray( $counts );
$this->assertArrayHasKey( 'tables_dropped', $counts );
$this->assertArrayHasKey( 'options_deleted', $counts );
$this->assertArrayHasKey( 'crons_cleared', $counts );
}
public function test_idempotent_on_empty_state(): void {
// Run twice — second run should be a no-op for tables (we already
// dropped them all in the first run). Options may not be 0 because
// other test classes share the same wp_itest_options table and may
// continually re-create wpdo_* rows; just verify that running cleanup
// twice in a row does not throw.
WPDO_Installer::drop_all_tables_for_current_blog();
$second = WPDO_Installer::drop_all_tables_for_current_blog();
$this->assertSame( 0, $second['tables_dropped'] );
$this->assertIsInt( $second['options_deleted'] );
$this->assertIsInt( $second['crons_cleared'] );
}
public function test_drops_known_options(): void {
// Stub `delete_option` does not interact with DB layer in our test stubs;
// it modifies `$GLOBALS['_wp_options']`. Verify counts work via the
// known-options list.
$GLOBALS['_wp_options']['wpdo_db_version'] = '2.14.0';
$GLOBALS['_wp_options']['wpdo_features'] = array();
$GLOBALS['_wp_options']['wpdo_health_alert'] = '1';
$counts = WPDO_Installer::drop_all_tables_for_current_blog();
// Note: the actual count depends on $wpdb->options interaction in
// the residual sweep. Just verify the structure works without errors.
$this->assertIsInt( $counts['options_deleted'] );
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test for WPDO_Installer::install_v2_tables() — PR-2 v2.0.0 schema.
*
* Verifies idempotent table creation for:
* - wp_*_wpdo_audit
* - wp_*_wpdo_shadow_diffs
* - wp_*_wpdo_site_metrics
* - wp_*_wpdo_uni_options
*
* @covers WPDO_Installer::install_v2_tables
* @covers WPDO_Installer::v2_tables_status
*/
class InstallerV2Test extends TestCase {
public static function setUpBeforeClass(): void {
// Drop v2 tables for a clean slate.
global $wpdb;
$p = $wpdb->prefix;
$wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_audit`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_shadow_diffs`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_site_metrics`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_uni_options`" );
}
public function test_v2_tables_initially_absent(): void {
$status = WPDO_Installer::v2_tables_status();
foreach ( $status as $table => $exists ) {
$this->assertFalse( $exists, "Expected {$table} to NOT exist initially" );
}
}
public function test_install_v2_tables_creates_all_four(): void {
WPDO_Installer::install_v2_tables();
$status = WPDO_Installer::v2_tables_status();
foreach ( $status as $table => $exists ) {
$this->assertTrue( $exists, "Expected {$table} to exist after install_v2_tables()" );
}
}
public function test_install_v2_tables_is_idempotent(): void {
WPDO_Installer::install_v2_tables();
WPDO_Installer::install_v2_tables(); // Second call must not error.
WPDO_Installer::install_v2_tables(); // Third for good measure.
$status = WPDO_Installer::v2_tables_status();
$this->assertCount( 4, $status );
$this->assertTrue( array_reduce( $status, static fn( $carry, $v ) => $carry && $v, true ) );
}
public function test_audit_table_has_required_columns(): void {
global $wpdb;
$p = $wpdb->prefix;
$cols = $wpdb->get_col( "SHOW COLUMNS FROM `{$p}wpdo_audit`" );
// PR-2 spec: op, value_before, value_after, source, trace_id are required.
foreach ( array( 'op', 'value_before', 'value_after', 'source', 'trace_id' ) as $required ) {
$this->assertContains( $required, $cols, "wpdo_audit missing column {$required}" );
}
}
public function test_shadow_diffs_table_has_required_columns(): void {
global $wpdb;
$p = $wpdb->prefix;
$cols = $wpdb->get_col( "SHOW COLUMNS FROM `{$p}wpdo_shadow_diffs`" );
foreach ( array( 'entity_type', 'entity_id', 'meta_key', 'postmeta_value', 'zone_value', 'diff_hash' ) as $required ) {
$this->assertContains( $required, $cols, "wpdo_shadow_diffs missing column {$required}" );
}
}
public function test_uni_options_has_unique_index_on_option_name(): void {
global $wpdb;
$p = $wpdb->prefix;
$indexes = $wpdb->get_results(
$wpdb->prepare(
'SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$p . 'wpdo_uni_options'
),
ARRAY_A
);
$found_unique = false;
foreach ( $indexes as $idx ) {
if ( 'option_name' === $idx['COLUMN_NAME'] && '0' === (string) $idx['NON_UNIQUE'] ) {
$found_unique = true;
break;
}
}
$this->assertTrue( $found_unique, 'Expected UNIQUE index on wpdo_uni_options.option_name' );
}
public function test_audit_table_indexes_for_query_performance(): void {
global $wpdb;
$p = $wpdb->prefix;
$indexes = $wpdb->get_col(
$wpdb->prepare(
'SELECT DISTINCT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$p . 'wpdo_audit'
)
);
// Performance-critical indexes per Part F.3 schema spec.
foreach ( array( 'idx_entity', 'idx_meta_key', 'idx_ts', 'idx_trace' ) as $required ) {
$this->assertContains( $required, $indexes, "wpdo_audit missing index {$required}" );
}
}
public static function tearDownAfterClass(): void {
// Leave v2 tables in place for subsequent tests / dev convenience.
// Cleanup happens via wp wpdo cleanup-uae-tables --confirm in real upgrades.
}
}
@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: wp_usermeta → wp_wpdo_user_membership backfill.
*
* Creates isolated tables (wp_itest_usermeta, wp_itest_wpdo_user_membership,
* wp_itest_wpdo_migration_status) and verifies that
* WPDO_Entity_Migration_Engine::migrate_group('user', 'membership') reads the
* EAV rows and produces correct flat-table rows.
*
* Requires real MariaDB (WPDO_TEST_DB_PASS env var must be set).
*/
class MemberBackfillIntegrationTest extends TestCase {
private const MEM_TABLE = 'wp_itest_wpdo_user_membership';
private const STATUS_TABLE = 'wp_itest_wpdo_migration_status';
private const USERMETA = 'wp_itest_usermeta';
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
self::load_engine_classes();
self::create_tables();
self::register_user_entity();
}
public static function tearDownAfterClass(): void {
global $wpdb;
foreach ( array( self::MEM_TABLE, self::STATUS_TABLE, self::USERMETA ) as $t ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$t}`" );
}
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::MEM_TABLE . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::STATUS_TABLE . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::USERMETA . '`' );
}
// ── Tests ─────────────────────────────────────────────────────────────────
public function test_migrates_membership_level_and_points(): void {
$this->seed_usermeta( array(
array( 'user_id' => 1, 'meta_key' => 'membership_level', 'meta_value' => 'gold' ),
array( 'user_id' => 1, 'meta_key' => 'points_balance', 'meta_value' => '500' ),
array( 'user_id' => 2, 'meta_key' => 'membership_level', 'meta_value' => 'silver' ),
array( 'user_id' => 2, 'meta_key' => 'points_balance', 'meta_value' => '200' ),
) );
$result = WPDO_Entity_Migration_Engine::migrate_group( 'user', 'membership', array( 'sleep_ms' => 0 ) );
$this->assertSame( 2, $result['migrated'], 'Expected 2 migrated rows' );
$this->assertSame( 0, $result['errors'] );
global $wpdb;
$row1 = $wpdb->get_row( "SELECT * FROM `" . self::MEM_TABLE . "` WHERE user_id = 1", ARRAY_A );
$this->assertNotNull( $row1 );
$this->assertSame( 'gold', $row1['membership_level'] );
$this->assertSame( '500', $row1['points_balance'] );
$row2 = $wpdb->get_row( "SELECT * FROM `" . self::MEM_TABLE . "` WHERE user_id = 2", ARRAY_A );
$this->assertNotNull( $row2 );
$this->assertSame( 'silver', $row2['membership_level'] );
$this->assertSame( '200', $row2['points_balance'] );
}
public function test_skips_users_with_no_managed_keys(): void {
$this->seed_usermeta( array(
array( 'user_id' => 3, 'meta_key' => 'some_other_meta', 'meta_value' => 'value' ),
) );
$result = WPDO_Entity_Migration_Engine::migrate_group( 'user', 'membership', array( 'sleep_ms' => 0 ) );
$this->assertSame( 0, $result['migrated'] );
$this->assertSame( 0, $result['errors'] );
}
public function test_dry_run_does_not_write_to_flat_table(): void {
$this->seed_usermeta( array(
array( 'user_id' => 4, 'meta_key' => 'membership_level', 'meta_value' => 'platinum' ),
) );
$result = WPDO_Entity_Migration_Engine::migrate_group(
'user', 'membership',
array( 'sleep_ms' => 0, 'dry_run' => true )
);
$this->assertTrue( $result['dry_run'] );
$this->assertSame( 1, $result['migrated'] );
global $wpdb;
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::MEM_TABLE . "`" );
$this->assertSame( 0, $count, 'Dry run must not write to flat table' );
}
public function test_row_count_matches_seeded_users(): void {
$this->seed_usermeta( array(
array( 'user_id' => 10, 'meta_key' => 'membership_level', 'meta_value' => 'bronze' ),
array( 'user_id' => 11, 'meta_key' => 'membership_level', 'meta_value' => 'bronze' ),
array( 'user_id' => 12, 'meta_key' => 'points_balance', 'meta_value' => '50' ),
) );
$result = WPDO_Entity_Migration_Engine::migrate_group( 'user', 'membership', array( 'sleep_ms' => 0 ) );
// user_id 10, 11 have membership_level; user_id 12 has points_balance.
$this->assertSame( 3, $result['migrated'] );
global $wpdb;
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::MEM_TABLE . "`" );
$this->assertSame( 3, $count );
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function seed_usermeta( array $rows ): void {
global $wpdb;
foreach ( $rows as $row ) {
$wpdb->insert( self::USERMETA, $row );
}
}
private static function load_engine_classes(): void {
$base = WPDO_PLUGIN_DIR;
$files = 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/adapters/class-tmdo-adapter-user.php',
'includes/engine/class-tmdo-entity-migration-engine.php',
);
foreach ( $files as $file ) {
if ( ! class_exists( self::class_for_file( $file ) ) ) {
require_once $base . $file;
}
}
}
private static function class_for_file( string $file ): string {
$map = array(
'interface-entity-adapter.php' => 'WPDO_Entity_Adapter_Interface',
'class-tmdo-type-caster.php' => 'WPDO_Type_Caster',
'class-tmdo-schema-manager.php' => 'WPDO_Schema_Manager',
'class-tmdo-entity-registry.php' => 'WPDO_Entity_Registry',
'class-tmdo-adapter-user.php' => 'WPDO_Adapter_User',
'class-tmdo-entity-migration-engine.php' => 'WPDO_Entity_Migration_Engine',
);
return $map[ basename( $file ) ] ?? '';
}
private static function create_tables(): void {
global $wpdb;
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::USERMETA . '` (
`umeta_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`meta_key` varchar(255) DEFAULT NULL,
`meta_value` longtext DEFAULT NULL,
PRIMARY KEY (`umeta_id`),
KEY `user_id` (`user_id`),
KEY `meta_key` (`meta_key`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::MEM_TABLE . '` (
`user_id` bigint(20) NOT NULL,
`membership_level` varchar(100) DEFAULT NULL,
`points_balance` bigint(20) DEFAULT 0,
`expires_at` datetime DEFAULT NULL,
`activated_at` datetime DEFAULT NULL,
`tier_source` varchar(255) DEFAULT NULL,
`custom_tier` varchar(255) DEFAULT NULL,
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::STATUS_TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`entity_type` varchar(50) NOT NULL,
`group_name` varchar(50) NOT NULL,
`last_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`total_migrated` bigint(20) 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 DEFAULT CHARSET=utf8mb4'
);
}
private static function register_user_entity(): void {
if ( ! class_exists( 'WPDO_Entity_Registry' ) ) {
return;
}
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group(
'user',
'membership',
array(
array(
'key' => 'membership_level',
'type' => 'enum',
'searchable' => true,
'options' => array( 'bronze', 'silver', 'gold', 'platinum', 'custom' ),
),
array( 'key' => 'points_balance', 'type' => 'integer', 'searchable' => true, 'default' => 0 ),
array( 'key' => 'expires_at', 'type' => 'datetime', 'searchable' => true ),
array( 'key' => 'activated_at', 'type' => 'datetime' ),
array( 'key' => 'tier_source', 'type' => 'text' ),
array( 'key' => 'custom_tier', 'type' => 'text' ),
)
);
}
}
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Migration_Orchestrator core SQL paths.
*
* Focuses on the parts that cannot be mocked at unit-test level:
* - Bulk SQL pivot (INSERT...SELECT...GROUP BY...ON DUPLICATE KEY UPDATE)
* - Idempotency (re-running pivot must not lose data, must not double-count)
* - Lock acquisition
* - Managed-key list correctness
*
* The orchestrator's full state-machine flow is exercised live on the dev
* environment (see PLAN.md / W-6 smoke-test); this test covers the
* deterministic SQL transforms that are easiest to regress.
*
* Requires real MariaDB (WPDO_TEST_DB_PASS env var must be set).
*/
final class MigrationOrchestratorTest extends TestCase {
private const USERMETA = 'wp_itest_usermeta';
private const FLAT = 'wp_itest_wpdo_user_core_profile';
public static function setUpBeforeClass(): void {
global $wpdb;
$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/adapters/class-tmdo-adapter-user.php',
'includes/engine/class-tmdo-entity-migration-engine.php',
) as $f ) {
require_once $base . $f;
}
// Drop + recreate to guarantee clean schema.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::USERMETA . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
$wpdb->query(
'CREATE TABLE `' . self::USERMETA . '` (
`umeta_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`meta_key` varchar(255) DEFAULT NULL,
`meta_value` longtext DEFAULT NULL,
PRIMARY KEY (`umeta_id`),
KEY `meta_key` (`meta_key`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$wpdb->query(
'CREATE TABLE `' . self::FLAT . '` (
`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'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::USERMETA . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
}
public function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE `' . self::USERMETA . '`' );
$wpdb->query( 'TRUNCATE `' . self::FLAT . '`' );
}
// ── Tests ────────────────────────────────────────────────────────────────
public function test_bulk_pivot_produces_one_row_per_user(): void {
$this->seed_eav( array(
array( 'user_id' => 10, 'meta_key' => 'first_name', 'meta_value' => 'Alice' ),
array( 'user_id' => 10, 'meta_key' => 'last_name', 'meta_value' => 'Adams' ),
array( 'user_id' => 10, 'meta_key' => 'nickname', 'meta_value' => 'al' ),
array( 'user_id' => 11, 'meta_key' => 'first_name', 'meta_value' => 'Bob' ),
array( 'user_id' => 11, 'meta_key' => 'description', 'meta_value' => 'engineer' ),
) );
$affected = $this->run_pivot();
// MySQL returns 2*N for INSERT...ON DUPLICATE on conflict, N for new inserts.
// Two new users → both INSERTs → affected_rows == 2.
$this->assertSame( 2, $affected );
global $wpdb;
$row10 = $wpdb->get_row( 'SELECT * FROM `' . self::FLAT . '` WHERE user_id=10', ARRAY_A );
$row11 = $wpdb->get_row( 'SELECT * FROM `' . self::FLAT . '` WHERE user_id=11', ARRAY_A );
$this->assertSame( 'Alice', $row10['first_name'] );
$this->assertSame( 'Adams', $row10['last_name'] );
$this->assertSame( 'al', $row10['nickname'] );
$this->assertNull( $row10['description'] );
$this->assertSame( 'Bob', $row11['first_name'] );
$this->assertSame( 'engineer', $row11['description'] );
$this->assertNull( $row11['last_name'] );
}
public function test_bulk_pivot_idempotent_re_run_preserves_data(): void {
$this->seed_eav( array(
array( 'user_id' => 20, 'meta_key' => 'first_name', 'meta_value' => 'Carol' ),
) );
$this->run_pivot();
$this->run_pivot(); // Second run must not lose or double-count data.
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' );
$this->assertSame( 1, $count, 'Re-run should not duplicate user_id row' );
$first = $wpdb->get_var( 'SELECT first_name FROM `' . self::FLAT . '` WHERE user_id=20' );
$this->assertSame( 'Carol', $first );
}
public function test_bulk_pivot_coalesce_preserves_existing_when_new_eav_subset(): void {
// Round 1: full data.
$this->seed_eav( array(
array( 'user_id' => 30, 'meta_key' => 'first_name', 'meta_value' => 'Dora' ),
array( 'user_id' => 30, 'meta_key' => 'last_name', 'meta_value' => 'Diaz' ),
) );
$this->run_pivot();
// Round 2: only first_name remains in EAV (last_name was cleaned).
global $wpdb;
$wpdb->query( "DELETE FROM `" . self::USERMETA . "` WHERE meta_key='last_name'" );
$this->run_pivot();
$row = $wpdb->get_row( 'SELECT * FROM `' . self::FLAT . '` WHERE user_id=30', ARRAY_A );
// COALESCE(VALUES(last_name), last_name) → keeps 'Diaz' even though new VALUES is NULL.
$this->assertSame( 'Dora', $row['first_name'] );
$this->assertSame( 'Diaz', $row['last_name'], 'COALESCE should preserve previously-migrated value when EAV is now empty' );
}
public function test_bulk_pivot_handles_empty_eav_gracefully(): void {
$affected = $this->run_pivot();
$this->assertSame( 0, $affected );
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' );
$this->assertSame( 0, $count );
}
public function test_bulk_pivot_uses_max_for_duplicate_meta_keys(): void {
// HivePress occasionally writes duplicate meta_value rows for the same key.
$this->seed_eav( array(
array( 'user_id' => 40, 'meta_key' => 'first_name', 'meta_value' => 'older_value' ),
array( 'user_id' => 40, 'meta_key' => 'first_name', 'meta_value' => 'newer_value' ),
) );
$this->run_pivot();
global $wpdb;
$first = $wpdb->get_var( 'SELECT first_name FROM `' . self::FLAT . '` WHERE user_id=40' );
// MAX() picks lexicographically larger; for our purpose this just guarantees
// deterministic behavior — no NULL, no error.
$this->assertNotNull( $first );
$this->assertContains( $first, array( 'older_value', 'newer_value' ) );
}
// ── Helpers ──────────────────────────────────────────────────────────────
private function seed_eav( array $rows ): void {
global $wpdb;
foreach ( $rows as $row ) {
$wpdb->insert( self::USERMETA, $row );
}
}
/**
* Local mirror of WPDO_Migration_Orchestrator::execute_bulk_pivot() against
* isolated test tables. Builds the same SQL form but pointing at our test
* usermeta and flat tables (the orchestrator targets $wpdb->usermeta).
*/
private function run_pivot(): int {
global $wpdb;
$keys = array( 'nickname', 'first_name', 'last_name', 'description' );
$cols = $keys;
$ph = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
$cases = array();
$updates = array();
foreach ( $cols as $col ) {
$cases[] = "MAX(CASE WHEN um.meta_key = '{$col}' THEN um.meta_value END) AS `{$col}`";
$updates[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)";
}
$sql = sprintf(
'INSERT INTO `%s` (`user_id`, %s)
SELECT um.user_id, %s
FROM `%s` um
WHERE um.meta_key IN (%s)
GROUP BY um.user_id
ON DUPLICATE KEY UPDATE %s',
self::FLAT,
implode( ', ', array_map( fn( $c ) => "`{$c}`", $cols ) ),
implode( ', ', $cases ),
self::USERMETA,
$ph,
implode( ', ', $updates )
);
$result = $wpdb->query( $wpdb->prepare( $sql, ...$keys ) );
return false === $result ? 0 : (int) $result;
}
}
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Points_Manager against real MariaDB.
*
* Verifies transaction discipline (BEGIN/COMMIT/ROLLBACK), atomic balance
* serialisation, ledger integrity, and overdraft protection using the real
* InnoDB transaction engine.
*
* Tables created with wp_itest_ prefix to avoid polluting production data.
*/
class PointsAtomicIntegrationTest extends TestCase {
private const MEM_TABLE = 'wp_itest_wpdo_user_membership';
private const LEDGER_TABLE = 'wp_itest_wpdo_user_points_ledger';
private const ERRORS_TABLE = 'wp_itest_wpdo_errors';
// ── Fixture lifecycle ────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Points_Manager' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-points-manager.php';
}
if ( ! class_exists( 'WPDO_DB' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-db.php';
}
if ( ! class_exists( 'WPDO_Logger' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-logger.php';
}
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::MEM_TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL,
`points_balance` bigint(20) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::LEDGER_TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL,
`delta` int(11) NOT NULL,
`balance_after` bigint(20) NOT NULL,
`reason` varchar(60) NOT NULL DEFAULT \'\',
`ref_id` bigint(20) DEFAULT NULL,
`ref_type` varchar(30) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_created` (`user_id`,`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::ERRORS_TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`severity` varchar(10) NOT NULL DEFAULT \'error\',
`component` varchar(60) NOT NULL DEFAULT \'\',
`context` varchar(60) NOT NULL DEFAULT \'\',
`message` text NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::MEM_TABLE . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::LEDGER_TABLE . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::ERRORS_TABLE . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::MEM_TABLE . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::LEDGER_TABLE . '`' );
}
// ── credit() happy path ─────────────────────────────────────────────────
public function test_credit_creates_membership_row(): void {
$result = WPDO_Points_Manager::credit( 1, 100, 'signup_bonus' );
$this->assertTrue( $result['ok'] );
$this->assertSame( 100, $result['balance'] );
$this->assertGreaterThan( 0, $result['ledger_id'] );
}
public function test_credit_accumulates_balance(): void {
WPDO_Points_Manager::credit( 2, 200, 'first' );
$result = WPDO_Points_Manager::credit( 2, 300, 'second' );
$this->assertTrue( $result['ok'] );
$this->assertSame( 500, $result['balance'] );
}
public function test_credit_writes_ledger_row(): void {
global $wpdb;
WPDO_Points_Manager::credit( 3, 50, 'test_reason' );
$row = $wpdb->get_row(
"SELECT * FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 3",
ARRAY_A
);
$this->assertNotNull( $row );
$this->assertSame( '50', $row['delta'] );
$this->assertSame( '50', $row['balance_after'] );
$this->assertSame( 'test_reason', $row['reason'] );
}
public function test_get_balance_reflects_credits(): void {
WPDO_Points_Manager::credit( 4, 75, 'top_up' );
$balance = WPDO_Points_Manager::get_balance( 4 );
$this->assertSame( 75, $balance );
}
// ── debit() happy path ──────────────────────────────────────────────────
public function test_debit_after_credit_reduces_balance(): void {
WPDO_Points_Manager::credit( 5, 300, 'load' );
$result = WPDO_Points_Manager::debit( 5, 100, 'purchase' );
$this->assertTrue( $result['ok'] );
$this->assertSame( 200, $result['balance'] );
}
public function test_debit_writes_negative_delta_to_ledger(): void {
global $wpdb;
WPDO_Points_Manager::credit( 6, 200, 'load' );
WPDO_Points_Manager::debit( 6, 50, 'spend' );
$rows = $wpdb->get_results(
"SELECT delta, balance_after FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 6 ORDER BY id",
ARRAY_A
);
$this->assertCount( 2, $rows );
$this->assertSame( '200', $rows[0]['delta'] ); // credit row.
$this->assertSame( '-50', $rows[1]['delta'] );
$this->assertSame( '150', $rows[1]['balance_after'] );
}
// ── debit() insufficient balance ─────────────────────────────────────────
public function test_debit_fails_when_insufficient(): void {
WPDO_Points_Manager::credit( 7, 50, 'load' );
$result = WPDO_Points_Manager::debit( 7, 100, 'purchase' );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'insufficient_balance', $result['error'] );
}
public function test_debit_failure_does_not_write_ledger(): void {
global $wpdb;
WPDO_Points_Manager::credit( 8, 30, 'load' );
WPDO_Points_Manager::debit( 8, 100, 'purchase' ); // should fail.
$count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 8 AND delta < 0"
);
$this->assertSame( 0, $count );
}
public function test_debit_failure_preserves_balance(): void {
WPDO_Points_Manager::credit( 9, 40, 'load' );
WPDO_Points_Manager::debit( 9, 200, 'purchase' ); // fails.
$balance = WPDO_Points_Manager::get_balance( 9 );
$this->assertSame( 40, $balance );
}
// ── sequential double-spend scenario ────────────────────────────────────
/**
* Simulate the classic double-spend race:
* Balance = 100. Two requests each try to debit 80.
* With FOR UPDATE serialisation: first succeeds → balance = 20,
* second then reads balance = 20 and correctly rejects (insufficient).
*/
public function test_sequential_debit_only_first_succeeds(): void {
WPDO_Points_Manager::credit( 10, 100, 'load' );
$first = WPDO_Points_Manager::debit( 10, 80, 'spend_1' );
$second = WPDO_Points_Manager::debit( 10, 80, 'spend_2' );
$this->assertTrue( $first['ok'], 'First debit should succeed' );
$this->assertSame( 20, $first['balance'] );
$this->assertFalse( $second['ok'], 'Second debit should fail (insufficient)' );
$this->assertSame( 'insufficient_balance', $second['error'] );
$this->assertSame( 20, WPDO_Points_Manager::get_balance( 10 ) );
}
public function test_sequential_debits_leave_correct_ledger_count(): void {
global $wpdb;
WPDO_Points_Manager::credit( 11, 200, 'load' );
WPDO_Points_Manager::debit( 11, 150, 'spend_1' ); // succeeds: balance=50.
WPDO_Points_Manager::debit( 11, 150, 'spend_2' ); // fails: insufficient.
$debit_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 11 AND delta < 0"
);
$this->assertSame( 1, $debit_count, 'Only one successful debit should be in ledger' );
}
// ── allow_overdraft ──────────────────────────────────────────────────────
public function test_overdraft_debit_goes_negative(): void {
WPDO_Points_Manager::credit( 12, 50, 'load' );
$result = WPDO_Points_Manager::debit( 12, 200, 'force', 0, '', true );
$this->assertTrue( $result['ok'] );
$this->assertSame( -150, $result['balance'] );
}
// ── ref_id / ref_type ────────────────────────────────────────────────────
public function test_credit_with_ref_id_and_type(): void {
global $wpdb;
WPDO_Points_Manager::credit( 13, 100, 'order_reward', 9999, 'order' );
$row = $wpdb->get_row(
"SELECT ref_id, ref_type FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 13",
ARRAY_A
);
$this->assertSame( '9999', $row['ref_id'] );
$this->assertSame( 'order', $row['ref_type'] );
}
// ── get_ledger() ─────────────────────────────────────────────────────────
public function test_get_ledger_returns_entries_newest_first(): void {
WPDO_Points_Manager::credit( 14, 100, 'a' );
WPDO_Points_Manager::credit( 14, 200, 'b' );
$ledger = WPDO_Points_Manager::get_ledger( 14 );
$this->assertCount( 2, $ledger );
// Newest-first: second credit (delta=200) should be first.
$this->assertSame( '200', $ledger[0]['delta'] );
$this->assertSame( '100', $ledger[1]['delta'] );
}
public function test_get_ledger_respects_limit(): void {
for ( $i = 1; $i <= 5; $i++ ) {
WPDO_Points_Manager::credit( 15, 10, "entry_{$i}" );
}
$ledger = WPDO_Points_Manager::get_ledger( 15, 3 );
$this->assertCount( 3, $ledger );
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Post_Migration::backfill_group_json() (v2.10.4).
*
* Validates row-by-row backfill for groups containing json-typed fields
* (attachment._wp_attachment_metadata, nav_menu_item._menu_item_classes,
* etc.) — these can't go through the bulk SQL pivot in backfill_group()
* because the values need PHP-level safe_unserialize → json_encode.
*
* Method is a thin wrapper over WPDO_Entity_Migration_Engine::migrate_group()
* which is already entity-agnostic; this test confirms the dispatch and
* sanity-checks output for post entity.
*/
class PostBackfillJsonTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const POSTMETA = 'wp_itest_postmeta';
private const FLAT = 'wp_itest_wpdo_post_attachment';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/interface-entity-adapter.php';
}
foreach ( array(
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-entity-migration-engine.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/integrations/class-tmdo-post-fields.php',
'includes/migration/class-tmdo-post-migration.php',
) as $rel ) {
$file = WPDO_PLUGIN_DIR . $rel;
if ( file_exists( $file ) ) {
require_once $file;
}
}
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTS . '` (
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_type varchar(20) NOT NULL DEFAULT \'post\',
PRIMARY KEY (ID),
KEY post_type (post_type)
) 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'
);
$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,
_wp_attached_file varchar(255) DEFAULT NULL,
_wp_attachment_metadata longtext DEFAULT NULL,
_wp_attachment_image_alt longtext DEFAULT NULL,
_wp_attachment_caption longtext 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'
);
// migration_status table needed by Entity_Migration_Engine
// (schema mirrors MemberBackfillIntegrationTest fixture).
$wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_migration_status`' );
$wpdb->query(
'CREATE TABLE `wp_itest_wpdo_migration_status` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
entity_type varchar(50) NOT NULL,
group_name varchar(50) NOT NULL,
last_id bigint(20) unsigned NOT NULL DEFAULT 0,
total_migrated bigint(20) 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)
) DEFAULT CHARACTER SET utf8mb4'
);
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 . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_migration_status`' );
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 . '`' );
$wpdb->query( 'TRUNCATE TABLE `wp_itest_wpdo_migration_status`' );
WPDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'attachment' );
}
private function seed_attachment( int $post_id, array $metadata, string $alt = '' ): void {
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => $post_id, 'post_type' => 'attachment' ) );
// Real WP serializes _wp_attachment_metadata via PHP serialize().
$wpdb->insert( self::POSTMETA, array(
'post_id' => $post_id,
'meta_key' => '_wp_attachment_metadata',
'meta_value' => serialize( $metadata ),
) );
if ( '' !== $alt ) {
$wpdb->insert( self::POSTMETA, array(
'post_id' => $post_id,
'meta_key' => '_wp_attachment_image_alt',
'meta_value' => $alt,
) );
}
}
// ── backfill_group_json() ────────────────────────────────────────────────
public function test_unserializes_attachment_metadata_to_json(): void {
$this->seed_attachment( 1, array(
'width' => 800,
'height' => 600,
'file' => '2026/04/test.jpg',
'sizes' => array(
'thumbnail' => array( 'width' => 150, 'height' => 150 ),
),
), 'Stress test alt' );
$result = WPDO_Post_Migration::backfill_group_json( 'attachment' );
$this->assertGreaterThan( 0, $result['migrated'] ?? 0 );
$this->assertSame( 0, $result['errors'] ?? -1 );
global $wpdb;
$row = $wpdb->get_row(
'SELECT _wp_attachment_metadata, _wp_attachment_image_alt FROM `' . self::FLAT . '` WHERE post_id = 1',
ARRAY_A
);
$this->assertNotNull( $row );
// metadata value should now be JSON.
$decoded = json_decode( (string) $row['_wp_attachment_metadata'], true );
$this->assertIsArray( $decoded );
$this->assertSame( 800, $decoded['width'] );
$this->assertSame( 'thumbnail', array_keys( $decoded['sizes'] )[0] );
// Non-json field still passes through.
$this->assertSame( 'Stress test alt', $row['_wp_attachment_image_alt'] );
}
public function test_idempotent_re_run(): void {
$this->seed_attachment( 1, array( 'width' => 100 ) );
WPDO_Post_Migration::backfill_group_json( 'attachment' );
// reset checkpoint so the engine reprocesses the same row.
WPDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'attachment' );
$result2 = WPDO_Post_Migration::backfill_group_json( 'attachment' );
$this->assertSame( 0, $result2['errors'] ?? -1 );
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' );
$this->assertSame( 1, $count, 'No duplicate row after re-run.' );
}
public function test_handles_already_serialized_string_safely(): void {
// Attacker-style: write an object signature into _wp_attachment_metadata
// (this is what safe_unserialize defends against).
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => 99, 'post_type' => 'attachment' ) );
$wpdb->insert( self::POSTMETA, array(
'post_id' => 99,
'meta_key' => '_wp_attachment_metadata',
'meta_value' => 'O:8:"stdClass":0:{}', // Object string — should be NULL'd
) );
$result = WPDO_Post_Migration::backfill_group_json( 'attachment' );
// Engine should NOT throw — safe_unserialize converts object to NULL.
$this->assertSame( 0, $result['errors'] ?? -1 );
}
public function test_handles_empty_postmeta_gracefully(): void {
$result = WPDO_Post_Migration::backfill_group_json( 'attachment' );
$this->assertSame( 0, $result['migrated'] ?? -1 );
$this->assertSame( 0, $result['errors'] ?? -1 );
}
public function test_returns_error_for_unknown_group(): void {
$result = WPDO_Post_Migration::backfill_group_json( 'bogus_group' );
// Engine returns error_result with 'error' key set.
$this->assertNotEmpty( $result['error'] ?? null );
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Post_Migration::benchmark_query() (v2.10.2).
*
* Verifies the benchmark method produces sensible timing comparison
* between wp_postmeta JOIN and flat-table JOIN for the same logical query.
*
* Tests focus on the API contract (input → output shape) since wall-clock
* timing is non-deterministic. Real performance numbers come from running
* `wp wpdo post-benchmark` on dev10 production data.
*/
class PostBenchmarkTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const POSTMETA = 'wp_itest_postmeta';
private const FLAT = 'wp_itest_wpdo_post_wc_product';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Schema_Manager' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/engine/class-tmdo-schema-manager.php';
}
if ( ! class_exists( 'WPDO_Post_Migration' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/migration/class-tmdo-post-migration.php';
}
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTS . '` (
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_type varchar(20) NOT NULL DEFAULT \'post\',
post_status varchar(20) NOT NULL DEFAULT \'publish\',
PRIMARY KEY (ID),
KEY post_type (post_type)
) 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'
);
$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,
PRIMARY KEY (id),
UNIQUE KEY uk_post_id (post_id),
KEY idx__price (_price)
) DEFAULT CHARACTER SET utf8mb4'
);
// Seed 50 products with _price = 10..59 in both tables.
for ( $i = 1; $i <= 50; $i++ ) {
$wpdb->insert( self::POSTS, array( 'ID' => $i, 'post_type' => 'product', 'post_status' => 'publish' ) );
$price = (string) ( 10 + $i );
$wpdb->insert( self::POSTMETA, array( 'post_id' => $i, 'meta_key' => '_price', 'meta_value' => $price ) );
$wpdb->insert( self::FLAT, array( 'post_id' => $i, '_price' => $price ) );
}
}
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 . '`' );
}
// ── benchmark_query() ────────────────────────────────────────────────────
public function test_benchmark_returns_expected_shape(): void {
$result = WPDO_Post_Migration::benchmark_query(
'product',
'_price',
'>=',
'30',
self::FLAT,
5
);
$this->assertArrayHasKey( 'samples', $result );
$this->assertArrayHasKey( 'postmeta_avg_ms', $result );
$this->assertArrayHasKey( 'flat_avg_ms', $result );
$this->assertArrayHasKey( 'speedup', $result );
$this->assertArrayHasKey( 'postmeta_rows', $result );
$this->assertArrayHasKey( 'flat_rows', $result );
$this->assertSame( 5, $result['samples'] );
$this->assertGreaterThan( 0.0, $result['postmeta_avg_ms'] );
$this->assertGreaterThan( 0.0, $result['flat_avg_ms'] );
}
public function test_benchmark_finds_same_rows_via_both_paths(): void {
// _price >= 30 should match products 20..50 (i.e. 31 rows).
$result = WPDO_Post_Migration::benchmark_query(
'product',
'_price',
'>=',
'30',
self::FLAT,
3
);
// Both paths must return the same count — verifies router correctness.
$this->assertSame( $result['postmeta_rows'], $result['flat_rows'] );
$this->assertGreaterThan( 0, $result['postmeta_rows'] );
}
public function test_benchmark_speedup_is_positive_number(): void {
$result = WPDO_Post_Migration::benchmark_query(
'product',
'_price',
'=',
'25',
self::FLAT,
3
);
$this->assertIsFloat( $result['speedup'] );
$this->assertGreaterThan( 0.0, $result['speedup'] );
}
public function test_benchmark_rejects_zero_samples(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Migration::benchmark_query( 'product', '_price', '=', '25', self::FLAT, 0 );
}
public function test_benchmark_rejects_invalid_compare(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Migration::benchmark_query( 'product', '_price', 'BOGUS', '25', self::FLAT, 3 );
}
public function test_benchmark_throws_when_flat_table_missing(): void {
$this->expectException( RuntimeException::class );
WPDO_Post_Migration::benchmark_query(
'product',
'_price',
'=',
'25',
'wp_itest_nonexistent_flat',
3
);
}
}
@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: Post Entity end-to-end lifecycle (v2.9.6).
*
* Stitches every phase shipped in v2.9.0 → v2.9.5 into a single regression
* net. If any future change breaks the contract between two phases (e.g.
* Postmeta_Cleaner output format vs Post_Migration::backfill_group input),
* this test fails before it ships.
*
* Phase coverage:
* v2.9.0 Postmeta_Cleaner::count_garbage / delete_garbage
* v2.9.1 Post_Fields::register_entity_fields → groups visible in Registry
* v2.9.2 Sync_Bridge guard (verified separately in SyncBridgeEntityGuardTest)
* v2.9.3 Post_Migration::diagnose / backfill_group / cleanup
* v2.9.4 Post_Stress_Tester::create / count / cleanup
* v2.9.5 Post_Migration::copy_legacy_hot_table / verify_legacy_cutover
*/
class PostEntityLifecycleTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const POSTMETA = 'wp_itest_postmeta';
private const FLAT = 'wp_itest_wpdo_post_wc_product';
public static function setUpBeforeClass(): void {
global $wpdb;
// Load full chain.
$plugin_dir = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/integrations/class-tmdo-post-fields.php',
'includes/migration/class-tmdo-post-migration.php',
'includes/class-tmdo-postmeta-cleaner.php',
'includes/class-tmdo-post-stress-tester.php',
) as $rel ) {
$file = $plugin_dir . $rel;
$class_name = self::class_for( $rel );
if ( $class_name && ! class_exists( $class_name ) && ! interface_exists( $class_name ) ) {
require_once $file;
}
}
$wpdb->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 . '`' );
}
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 );
}
}
}
+239
View File
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Post_Migration::copy_legacy_hot_table() (v2.9.5).
*
* Verifies non-destructive cutover from legacy `wpdo_hot_<post_type>` table
* to the new `wp_wpdo_post_<group>` flat table:
*
* - copies common columns by name intersection
* - skips auto_increment id + updated_at columns (let flat manage them)
* - idempotent (re-run doesn't duplicate; ON DUPLICATE KEY UPDATE)
* - leaves the legacy table untouched (safety net for v3.0.0 DROP)
* - verify_legacy_cutover() reports row count + sample mismatches
*/
class PostLegacyCutoverTest extends TestCase {
private const HOT_TABLE = 'wp_itest_wpdo_hot_hp_listing';
private const FLAT_TABLE = 'wp_itest_wpdo_post_hp_listing_core';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Post_Migration' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/migration/class-tmdo-post-migration.php';
}
// Legacy hot table — narrower schema, hp_booking_enabled is hot-only.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::HOT_TABLE . '`' );
$wpdb->query(
'CREATE TABLE `' . self::HOT_TABLE . '` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
hp_price decimal(10,2) NOT NULL DEFAULT 0.00,
hp_featured tinyint(1) NOT NULL DEFAULT 0,
hp_verified tinyint(1) NOT NULL DEFAULT 0,
hp_expired_time bigint(20) NOT NULL DEFAULT 0,
hp_featured_time bigint(20) NOT NULL DEFAULT 0,
updated_at datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
hp_booking_enabled tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY post_id (post_id)
) DEFAULT CHARACTER SET utf8mb4'
);
// Flat target — wider schema, includes hp_status/hp_vendor not in hot.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT_TABLE . '`' );
$wpdb->query(
'CREATE TABLE `' . self::FLAT_TABLE . '` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
hp_price decimal(18,6) DEFAULT NULL,
hp_status varchar(100) DEFAULT NULL,
hp_featured bigint(20) DEFAULT NULL,
hp_verified bigint(20) DEFAULT NULL,
hp_vendor bigint(20) DEFAULT NULL,
hp_expired_time bigint(20) DEFAULT NULL,
hp_featured_time bigint(20) DEFAULT NULL,
hp_view_count bigint(20) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_post_id (post_id)
) DEFAULT CHARACTER SET utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::HOT_TABLE . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT_TABLE . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::HOT_TABLE . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::FLAT_TABLE . '`' );
}
private function seed_hot( int $post_id, array $cols ): void {
global $wpdb;
$wpdb->insert( self::HOT_TABLE, array_merge( array( 'post_id' => $post_id ), $cols ) );
}
// ── copy_legacy_hot_table() ──────────────────────────────────────────────
public function test_copy_legacy_hot_table_copies_all_rows(): void {
// Seed 5 rows.
for ( $i = 1; $i <= 5; $i++ ) {
$this->seed_hot( 100 + $i, array(
'hp_price' => 50.00 + $i,
'hp_featured' => $i % 2,
'hp_verified' => 1,
'hp_expired_time' => 9999999 + $i,
'hp_featured_time' => 0,
) );
}
$result = WPDO_Post_Migration::copy_legacy_hot_table(
'hp_listing',
self::HOT_TABLE,
self::FLAT_TABLE
);
$this->assertSame( 5, $result['copied'] );
global $wpdb;
$flat_count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '`' );
$this->assertSame( 5, $flat_count );
// Verify a row's data round-trips.
$row = $wpdb->get_row( 'SELECT hp_price, hp_featured, hp_verified, hp_expired_time FROM `' . self::FLAT_TABLE . '` WHERE post_id = 103', ARRAY_A );
$this->assertSame( '53.000000', $row['hp_price'] );
$this->assertSame( '1', $row['hp_featured'] ); // 3 % 2 = 1
$this->assertSame( '1', $row['hp_verified'] );
$this->assertSame( '10000002', $row['hp_expired_time'] );
}
public function test_copy_skips_id_and_updated_at_columns(): void {
$this->seed_hot( 200, array(
'hp_price' => 99.99,
'hp_featured' => 0,
'hp_verified' => 1,
'hp_expired_time' => 0,
'hp_featured_time' => 0,
) );
WPDO_Post_Migration::copy_legacy_hot_table(
'hp_listing',
self::HOT_TABLE,
self::FLAT_TABLE
);
global $wpdb;
// Flat row's id should be auto-assigned (not the hot row's id).
// Flat row's updated_at should be CURRENT_TIMESTAMP (not 0000-00-00).
$row = $wpdb->get_row( 'SELECT id, updated_at FROM `' . self::FLAT_TABLE . '` WHERE post_id = 200', ARRAY_A );
$this->assertNotEmpty( $row['updated_at'] );
$this->assertNotEquals( '0000-00-00 00:00:00', $row['updated_at'] );
}
public function test_copy_legacy_hot_table_is_idempotent(): void {
$this->seed_hot( 300, array(
'hp_price' => 10.00,
'hp_featured' => 0,
'hp_verified' => 1,
'hp_expired_time' => 0,
'hp_featured_time' => 0,
) );
WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE );
WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE );
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '`' );
$this->assertSame( 1, $count, 'Re-running copy is idempotent — UPSERT, no duplicate.' );
}
public function test_copy_does_not_modify_legacy_hot_table(): void {
$this->seed_hot( 400, array(
'hp_price' => 25.00,
'hp_featured' => 0,
'hp_verified' => 1,
'hp_expired_time' => 0,
'hp_featured_time' => 0,
) );
global $wpdb;
$before = $wpdb->get_results( 'SELECT * FROM `' . self::HOT_TABLE . '` ORDER BY id', ARRAY_A );
WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE );
$after = $wpdb->get_results( 'SELECT * FROM `' . self::HOT_TABLE . '` ORDER BY id', ARRAY_A );
$this->assertEquals( $before, $after, 'Legacy hot table must remain untouched (safety net for v3.0.0).' );
}
public function test_copy_returns_zero_when_hot_table_empty(): void {
$result = WPDO_Post_Migration::copy_legacy_hot_table(
'hp_listing',
self::HOT_TABLE,
self::FLAT_TABLE
);
$this->assertSame( 0, $result['copied'] );
}
public function test_copy_throws_when_hot_table_missing(): void {
$this->expectException( RuntimeException::class );
WPDO_Post_Migration::copy_legacy_hot_table(
'hp_listing',
'wp_itest_nonexistent_hot',
self::FLAT_TABLE
);
}
// ── verify_legacy_cutover() ──────────────────────────────────────────────
public function test_verify_reports_match_when_counts_equal(): void {
for ( $i = 1; $i <= 3; $i++ ) {
$this->seed_hot( 500 + $i, array(
'hp_price' => $i * 10,
'hp_featured' => 0,
'hp_verified' => 1,
'hp_expired_time' => 0,
'hp_featured_time' => 0,
) );
}
WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE );
$verify = WPDO_Post_Migration::verify_legacy_cutover( self::HOT_TABLE, self::FLAT_TABLE );
$this->assertSame( 3, $verify['hot_rows'] );
$this->assertSame( 3, $verify['flat_rows'] );
$this->assertSame( 0, $verify['mismatched_rows'] );
$this->assertTrue( $verify['ok'] );
}
public function test_verify_reports_mismatch_when_flat_lags_hot(): void {
// Seed hot with 3 rows.
for ( $i = 1; $i <= 3; $i++ ) {
$this->seed_hot( 600 + $i, array(
'hp_price' => $i * 10,
'hp_featured' => 0,
'hp_verified' => 1,
'hp_expired_time' => 0,
'hp_featured_time' => 0,
) );
}
// Don't run copy — flat stays empty.
$verify = WPDO_Post_Migration::verify_legacy_cutover( self::HOT_TABLE, self::FLAT_TABLE );
$this->assertSame( 3, $verify['hot_rows'] );
$this->assertSame( 0, $verify['flat_rows'] );
$this->assertFalse( $verify['ok'] );
}
}
+240
View File
@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Post_Migration core methods (v2.9.3).
*
* Verifies diagnose(), backfill_group(), cutover() against real MariaDB,
* mirroring the user-side WPDO_Migration_Orchestrator's contract but using
* a new independent class so the user orchestrator's 1105 lines stay frozen.
*
* Requires real MariaDB (WPDO_TEST_DB_PASS env var must be set).
*/
class PostMigrationTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const POSTMETA = 'wp_itest_postmeta';
private const FLAT = 'wp_itest_wpdo_post_wc_product';
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
// Load post entity chain.
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/interface-entity-adapter.php';
}
if ( ! class_exists( 'WPDO_Entity_Registry' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/engine/class-tmdo-entity-registry.php';
}
if ( ! class_exists( 'WPDO_Mode_Manager' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/engine/class-tmdo-mode-manager.php';
}
if ( ! class_exists( 'WPDO_Schema_Manager' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/engine/class-tmdo-schema-manager.php';
}
if ( ! class_exists( 'WPDO_Adapter_Post' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/class-tmdo-adapter-post.php';
}
if ( ! class_exists( 'WPDO_Post_Fields' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-post-fields.php';
}
if ( ! class_exists( 'WPDO_Post_Migration' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/migration/class-tmdo-post-migration.php';
}
// wp_posts (minimal — only ID + post_type).
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTS . '` (
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_type varchar(20) NOT NULL DEFAULT \'post\',
PRIMARY KEY (ID),
KEY post_type (post_type)
) DEFAULT CHARACTER SET utf8mb4'
);
// wp_postmeta — same schema as production WP.
$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'
);
// Flat target for wc_product group (subset of full schema for test).
$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,
_stock bigint(20) DEFAULT NULL,
_stock_status varchar(100) DEFAULT NULL,
_sku varchar(255) 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'
);
// Reset Entity Registry + register post adapter + post fields.
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 polluting later tests.
$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 . '`' );
}
private function seed_post( int $id, string $post_type ): void {
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => $id, 'post_type' => $post_type ) );
}
private function seed_meta( int $post_id, string $key, string $value ): void {
global $wpdb;
$wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => $key, 'meta_value' => $value ) );
}
// ── diagnose() ────────────────────────────────────────────────────────────
public function test_diagnose_reports_posts_postmeta_ratio(): void {
// 5 posts, 12 postmeta rows → ratio 2.4
for ( $i = 1; $i <= 5; $i++ ) {
$this->seed_post( $i, 'post' );
}
for ( $i = 0; $i < 12; $i++ ) {
$this->seed_meta( ( $i % 5 ) + 1, 'random_key', 'v' );
}
$result = WPDO_Post_Migration::diagnose();
$this->assertSame( 5, $result['posts'] );
$this->assertSame( 12, $result['postmeta'] );
$this->assertSame( 2.4, $result['ratio'] );
}
public function test_diagnose_reports_eav_rows_per_managed_group(): void {
$this->seed_post( 1, 'product' );
$this->seed_meta( 1, '_price', '99.99' );
$this->seed_meta( 1, '_stock', '5' );
$this->seed_meta( 1, 'unrelated_key', 'x' ); // not in any group
$result = WPDO_Post_Migration::diagnose();
$this->assertArrayHasKey( 'groups', $result );
$this->assertArrayHasKey( 'wc_product', $result['groups'] );
$this->assertSame(
2,
$result['groups']['wc_product']['eav_rows'],
'wc_product group has 2 EAV rows: _price + _stock (unrelated_key excluded).'
);
}
public function test_diagnose_reports_zero_eav_for_unused_group(): void {
$this->seed_post( 1, 'post' );
$this->seed_meta( 1, 'random_key', 'v' );
$result = WPDO_Post_Migration::diagnose();
// nav_menu_item group has no postmeta seeded.
$this->assertSame( 0, $result['groups']['nav_menu_item']['eav_rows'] );
}
public function test_diagnose_reports_post_mode(): void {
$result = WPDO_Post_Migration::diagnose();
$this->assertArrayHasKey( 'mode', $result );
// Default post mode is 'disabled' per Mode_Manager defaults().
$this->assertSame( 'disabled', $result['mode'] );
}
// ── backfill_group() — bulk SQL pivot ─────────────────────────────────────
public function test_backfill_group_pivots_wc_product_keys(): void {
$this->seed_post( 1, 'product' );
$this->seed_meta( 1, '_price', '99.99' );
$this->seed_meta( 1, '_regular_price', '120.00' );
$this->seed_meta( 1, '_stock', '5' );
$this->seed_meta( 1, '_stock_status', 'instock' );
$this->seed_meta( 1, '_sku', 'SKU-001' );
$this->seed_post( 2, 'product' );
$this->seed_meta( 2, '_price', '49.50' );
$this->seed_meta( 2, '_stock_status', 'outofstock' );
$result = WPDO_Post_Migration::backfill_group( 'wc_product' );
$this->assertSame( 2, $result['migrated'], 'Two posts produce two flat rows.' );
global $wpdb;
$row1 = $wpdb->get_row( 'SELECT _price, _stock, _sku FROM `' . self::FLAT . '` WHERE post_id = 1', ARRAY_A );
$this->assertSame( '99.990000', $row1['_price'], 'Price decimal stored with full precision.' );
$this->assertSame( '5', $row1['_stock'] );
$this->assertSame( 'SKU-001', $row1['_sku'] );
$row2 = $wpdb->get_row( 'SELECT _price, _stock_status, _sku FROM `' . self::FLAT . '` WHERE post_id = 2', ARRAY_A );
$this->assertSame( '49.500000', $row2['_price'] );
$this->assertSame( 'outofstock', $row2['_stock_status'] );
$this->assertNull( $row2['_sku'], 'Unset key remains NULL in flat row.' );
}
public function test_backfill_group_is_idempotent(): void {
$this->seed_post( 1, 'product' );
$this->seed_meta( 1, '_price', '50.00' );
WPDO_Post_Migration::backfill_group( 'wc_product' );
$result_second = WPDO_Post_Migration::backfill_group( 'wc_product' );
$this->assertSame( 1, $result_second['migrated'], 'Re-running backfill is idempotent (UPSERT).' );
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' );
$this->assertSame( 1, $count, 'No duplicate rows after re-run.' );
}
public function test_backfill_group_skips_posts_of_wrong_type(): void {
// _price on a non-product post should NOT migrate to wc_product flat.
$this->seed_post( 99, 'post' );
$this->seed_meta( 99, '_price', '100.00' );
$result = WPDO_Post_Migration::backfill_group( 'wc_product' );
$this->assertSame( 0, $result['migrated'], 'Posts of wrong type are excluded by post_type filter.' );
}
public function test_backfill_group_rejects_invalid_group(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Migration::backfill_group( 'bogus_group' );
}
}
+245
View File
@@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Post_Query_Router (v2.10.1).
*
* Verifies meta_query rewriting when post mode is reads_from_flat (i.e.
* shadow_read or aeav_only). The router strips clauses targeting registered
* Entity Registry keys, JOINs the corresponding flat table, and appends
* WHERE conditions in SQL.
*
* 🔒 Frozen contract:
* - mode=disabled → router pass-through (zero modification)
* - mode=dual_write → router pass-through (wp_postmeta still source-of-truth)
* - mode=shadow_read → router rewrites (flat is read-replica candidate)
* - mode=aeav_only → router rewrites (flat is source-of-truth)
*/
class PostQueryRouterTest extends TestCase {
public static function setUpBeforeClass(): void {
$plugin_dir = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/integrations/class-tmdo-post-fields.php',
'includes/query/class-tmdo-post-query-router.php',
) as $rel ) {
$file = $plugin_dir . $rel;
if ( file_exists( $file ) ) {
require_once $file;
}
}
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 {
$ref = new ReflectionClass( WPDO_Mode_Manager::class );
$cache = $ref->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, null );
WPDO_Entity_Registry::init();
}
private function set_post_mode( string $mode ): void {
$ref = new ReflectionClass( WPDO_Mode_Manager::class );
$cache = $ref->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, array(
'post' => $mode,
'user' => 'aeav_only',
'term' => 'dual_write',
'comment' => 'dual_write',
) );
}
private function make_query( array $vars ): WP_Query {
$q = new WP_Query();
foreach ( $vars as $k => $v ) {
$q->set( $k, $v );
}
return $q;
}
// ── pre_get_posts gate (mode-aware) ───────────────────────────────────────
public function test_pass_through_when_mode_disabled(): void {
$this->set_post_mode( 'disabled' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
),
) );
$original = $query->get( 'meta_query' );
$router->pre_get_posts( $query );
$this->assertSame(
$original,
$query->get( 'meta_query' ),
'mode=disabled: meta_query must be untouched.'
);
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
}
public function test_pass_through_when_mode_dual_write(): void {
$this->set_post_mode( 'dual_write' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
),
) );
$original = $query->get( 'meta_query' );
$router->pre_get_posts( $query );
$this->assertSame(
$original,
$query->get( 'meta_query' ),
'mode=dual_write: wp_postmeta is still source-of-truth, no rewrite.'
);
}
// ── pre_get_posts rewrite (mode=aeav_only) ───────────────────────────────
public function test_rewrites_meta_query_when_mode_aeav_only(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
),
) );
$router->pre_get_posts( $query );
// Original meta_query stripped of registered keys.
$remaining = $query->get( 'meta_query' );
$this->assertEmpty(
$remaining,
'aeav_only: registered keys removed from meta_query.'
);
// Routed clauses captured under wpdo_post_clauses query var.
$routed = $query->get( 'wpdo_post_clauses' );
$this->assertIsArray( $routed );
$this->assertNotEmpty( $routed );
}
public function test_keeps_unmanaged_keys_in_meta_query(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
),
) );
$router->pre_get_posts( $query );
$remaining = $query->get( 'meta_query' );
$this->assertCount( 1, $remaining );
// Original index preserved (k=1 since k=0 was the routed _price clause).
$first_clause = reset( $remaining );
$this->assertSame(
'unmanaged_attr',
$first_clause['key'],
'Unmanaged key remains in meta_query (Hook Bus pass-through).'
);
}
public function test_skips_admin_requests(): void {
$this->set_post_mode( 'aeav_only' );
$prev_admin = $GLOBALS['_wp_is_admin'] ?? false;
$GLOBALS['_wp_is_admin'] = true;
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
),
) );
$original = $query->get( 'meta_query' );
$router->pre_get_posts( $query );
$this->assertSame(
$original,
$query->get( 'meta_query' ),
'is_admin requests should not be rewritten.'
);
$GLOBALS['_wp_is_admin'] = $prev_admin;
}
// ── posts_join / posts_where (SQL emission) ──────────────────────────────
public function test_posts_join_emits_left_join_for_each_routed_post_type(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50' ),
),
'wpdo_post_clauses' => array(),
) );
$router->pre_get_posts( $query );
$join = $router->posts_join( '', $query );
$this->assertStringContainsString( 'LEFT JOIN', $join );
$this->assertStringContainsString( 'wpdo_post_wc_product', $join );
}
public function test_posts_where_appends_condition_for_routed_clause(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '99', 'compare' => '=' ),
),
) );
$router->pre_get_posts( $query );
$where = $router->posts_where( '', $query );
$this->assertStringContainsString( '`_price`', $where );
$this->assertStringContainsString( "'99'", $where );
}
public function test_pass_through_when_no_meta_query(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array( 'post_type' => 'product' ) );
$router->pre_get_posts( $query );
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
}
}
@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Post_Shadow_Verifier (v2.10.3).
*
* Verifies sample-and-compare logic between flat tables and wp_postmeta.
* Result tuple: {sampled, matched, diffs, missing_flat, missing_postmeta}.
*
* Logger integration is exercised via WPDO_Shadow_Diff_Logger; this test
* focuses on the verifier's sampling + counting contract.
*/
class PostShadowVerifierTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const POSTMETA = 'wp_itest_postmeta';
private const FLAT = 'wp_itest_wpdo_post_wc_product';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/interface-entity-adapter.php';
}
foreach ( array(
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/integrations/class-tmdo-post-fields.php',
'includes/class-tmdo-post-shadow-verifier.php',
) as $rel ) {
$file = WPDO_PLUGIN_DIR . $rel;
if ( file_exists( $file ) ) {
require_once $file;
}
}
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTS . '` (
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_type varchar(20) NOT NULL DEFAULT \'post\',
post_status varchar(20) NOT NULL DEFAULT \'publish\',
PRIMARY KEY (ID),
KEY post_type (post_type)
) 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'
);
$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,
_stock_status varchar(100) DEFAULT NULL,
_sku longtext DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_post_id (post_id)
) DEFAULT CHARACTER SET utf8mb4'
);
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 . '`' );
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 . '`' );
}
private function seed_consistent( int $post_id, string $price, string $stock ): void {
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => $post_id, 'post_type' => 'product', 'post_status' => 'publish' ) );
$wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => '_price', 'meta_value' => $price ) );
$wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => '_stock_status', 'meta_value' => $stock ) );
$wpdb->insert( self::FLAT, array( 'post_id' => $post_id, '_price' => $price, '_stock_status' => $stock ) );
}
private function seed_diverged( int $post_id, string $pm_price, string $flat_price ): void {
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => $post_id, 'post_type' => 'product', 'post_status' => 'publish' ) );
$wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => '_price', 'meta_value' => $pm_price ) );
$wpdb->insert( self::FLAT, array( 'post_id' => $post_id, '_price' => $flat_price ) );
}
// ── sample_compare() ─────────────────────────────────────────────────────
public function test_returns_expected_shape(): void {
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_price', '_stock_status' ),
5
);
$this->assertArrayHasKey( 'sampled', $result );
$this->assertArrayHasKey( 'matched', $result );
$this->assertArrayHasKey( 'diffs', $result );
$this->assertArrayHasKey( 'missing_flat', $result );
$this->assertArrayHasKey( 'missing_postmeta', $result );
}
public function test_all_match_when_data_is_consistent(): void {
for ( $i = 1; $i <= 5; $i++ ) {
$this->seed_consistent( $i, '50.00', 'instock' );
}
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_price' ),
5
);
$this->assertSame( 5, $result['sampled'] );
$this->assertSame( 5, $result['matched'] );
$this->assertSame( 0, $result['diffs'] );
$this->assertSame( 0, $result['missing_flat'] );
}
public function test_detects_divergence_between_flat_and_postmeta(): void {
// Two diverged: pm has 50, flat has 60.
$this->seed_diverged( 1, '50', '60' );
$this->seed_diverged( 2, '99', '88' );
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_price' ),
5
);
$this->assertSame( 2, $result['sampled'] );
$this->assertSame( 0, $result['matched'] );
$this->assertSame( 2, $result['diffs'] );
}
public function test_counts_missing_flat_when_flat_row_absent(): void {
// Post + postmeta exist, but no flat row.
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => 100, 'post_type' => 'product', 'post_status' => 'publish' ) );
$wpdb->insert( self::POSTMETA, array( 'post_id' => 100, 'meta_key' => '_price', 'meta_value' => '99' ) );
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_price' ),
5
);
$this->assertSame( 1, $result['sampled'] );
$this->assertSame( 1, $result['missing_flat'] );
}
public function test_counts_missing_postmeta_when_pm_absent(): void {
// Post + flat exist, but no postmeta.
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => 200, 'post_type' => 'product', 'post_status' => 'publish' ) );
$wpdb->insert( self::FLAT, array( 'post_id' => 200, '_price' => '50' ) );
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_price' ),
5
);
$this->assertSame( 1, $result['sampled'] );
$this->assertSame( 1, $result['missing_postmeta'] );
}
public function test_returns_zero_when_no_posts_of_type(): void {
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_price' ),
5
);
$this->assertSame( 0, $result['sampled'] );
$this->assertSame( 0, $result['matched'] );
}
public function test_caps_sample_at_available_post_count(): void {
// 3 posts, ask for 10 samples → only 3 sampled.
$this->seed_consistent( 1, '10', 'instock' );
$this->seed_consistent( 2, '20', 'instock' );
$this->seed_consistent( 3, '30', 'instock' );
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_price' ),
10
);
$this->assertSame( 3, $result['sampled'] );
$this->assertSame( 3, $result['matched'] );
}
public function test_rejects_zero_sample_size(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Shadow_Verifier::sample_compare( 'product', 'wc_product', self::FLAT, array( '_price' ), 0 );
}
public function test_rejects_empty_keys(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Shadow_Verifier::sample_compare( 'product', 'wc_product', self::FLAT, array(), 5 );
}
// ── v2.10.5: serialize vs JSON loose equality ────────────────────────────
public function test_treats_serialized_array_equal_to_json_array(): void {
// pm side has serialized array; flat side has JSON for the same data.
// post_id=10, key=_stock_status (we reuse this column to inject test values).
// Use a dedicated key for clarity by re-purposing the keys array.
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => 10, 'post_type' => 'product', 'post_status' => 'publish' ) );
$wpdb->insert( self::POSTMETA, array(
'post_id' => 10,
'meta_key' => '_sku',
'meta_value' => serialize( array( 'a', 'b', 'c' ) ),
) );
$wpdb->insert( self::FLAT, array(
'post_id' => 10,
'_sku' => wp_json_encode( array( 'a', 'b', 'c' ) ),
) );
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_sku' ),
5
);
$this->assertSame(
1,
$result['matched'],
'serialized array vs JSON-encoded same array should match.'
);
$this->assertSame( 0, $result['diffs'], 'No false-positive diff.' );
}
public function test_treats_serialized_assoc_equal_to_json_assoc(): void {
global $wpdb;
$assoc = array( 'width' => 100, 'height' => 200 );
$wpdb->insert( self::POSTS, array( 'ID' => 11, 'post_type' => 'product', 'post_status' => 'publish' ) );
$wpdb->insert( self::POSTMETA, array(
'post_id' => 11,
'meta_key' => '_sku',
'meta_value' => serialize( $assoc ),
) );
$wpdb->insert( self::FLAT, array(
'post_id' => 11,
'_sku' => wp_json_encode( $assoc ),
) );
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_sku' ),
5
);
$this->assertSame( 1, $result['matched'] );
$this->assertSame( 0, $result['diffs'] );
}
public function test_genuine_diff_still_detected_after_loose_equal_widening(): void {
// Real divergence — must still be flagged even with loosened compare.
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => 12, 'post_type' => 'product', 'post_status' => 'publish' ) );
$wpdb->insert( self::POSTMETA, array(
'post_id' => 12,
'meta_key' => '_sku',
'meta_value' => serialize( array( 1, 2, 3 ) ),
) );
$wpdb->insert( self::FLAT, array(
'post_id' => 12,
'_sku' => wp_json_encode( array( 9, 9, 9 ) ),
) );
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_sku' ),
5
);
$this->assertSame( 0, $result['matched'] );
$this->assertSame( 1, $result['diffs'] );
}
}
+476
View File
@@ -0,0 +1,476 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Post_Stress_Tester (v2.9.4).
*
* Verifies the post-side stress tester contract:
* - create() bulk-inserts N posts with 19 wc_product meta keys
* - count_test_posts() returns the correct count
* - cleanup() removes ALL test posts + their postmeta + flat rows
* - test posts use post_title prefix WPDO_STRESS_TEST_ for identification
*
* Mirrors the user-side WPDO_User_Stress_Tester contract but with a much
* narrower API surface — full polling/cron/benchmark UI deferred (v2.9.4
* scope is bulk fixture generation for v2.9.5 cutover validation).
*/
class PostStressTesterTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const POSTMETA = 'wp_itest_postmeta';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Post_Stress_Tester' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-post-stress-tester.php';
}
$wpdb->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'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
}
// ── create() ──────────────────────────────────────────────────────────────
public function test_create_inserts_requested_count_of_products(): void {
$result = WPDO_Post_Stress_Tester::create( 'product', 5 );
$this->assertSame( 5, $result['created'] );
global $wpdb;
$count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_type = 'product'"
);
$this->assertSame( 5, $count );
}
public function test_create_uses_stress_test_prefix_in_post_title(): void {
WPDO_Post_Stress_Tester::create( 'product', 3 );
global $wpdb;
$prefix_count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_title LIKE %s",
WPDO_Post_Stress_Tester::TEST_POST_PREFIX . '%'
)
);
$this->assertSame( 3, $prefix_count );
}
public function test_create_seeds_postmeta_for_each_post(): void {
WPDO_Post_Stress_Tester::create( 'product', 2 );
global $wpdb;
// Each test product should have at least the 5 critical wc_product keys.
$meta_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTMETA . "`"
);
$this->assertGreaterThanOrEqual( 10, $meta_count, 'At least 5 keys × 2 posts = 10 rows.' );
// Verify _price was set on every test product.
$price_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = '_price'"
);
$this->assertSame( 2, $price_count );
}
public function test_create_supports_hp_listing_post_type(): void {
$result = WPDO_Post_Stress_Tester::create( 'hp_listing', 4 );
$this->assertSame( 4, $result['created'] );
global $wpdb;
$count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_type = 'hp_listing'"
);
$this->assertSame( 4, $count );
// hp_listing seeds hp_price, not _price.
$hp_price_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = 'hp_price'"
);
$this->assertSame( 4, $hp_price_count );
}
public function test_create_rejects_unsupported_post_type(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Stress_Tester::create( 'bogus_type', 3 );
}
public function test_create_rejects_zero_count(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Stress_Tester::create( 'product', 0 );
}
public function test_create_rejects_excessive_count(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Stress_Tester::create( 'product', 100001 );
}
// ── count_test_posts() ────────────────────────────────────────────────────
public function test_count_test_posts_returns_zero_for_empty(): void {
$this->assertSame( 0, WPDO_Post_Stress_Tester::count_test_posts() );
}
public function test_count_test_posts_counts_only_stress_prefix(): void {
// Seed 2 stress posts + 1 real post.
WPDO_Post_Stress_Tester::create( 'product', 2 );
global $wpdb;
$wpdb->insert( self::POSTS, array(
'ID' => 9999,
'post_title' => 'Real product not from stress',
'post_type' => 'product',
'post_content' => '',
'post_excerpt' => '',
'post_content_filtered' => '',
'to_ping' => '',
'pinged' => '',
) );
$this->assertSame( 2, WPDO_Post_Stress_Tester::count_test_posts() );
}
// ── cleanup() ─────────────────────────────────────────────────────────────
public function test_cleanup_removes_all_stress_posts_and_their_meta(): void {
WPDO_Post_Stress_Tester::create( 'product', 5 );
// Verify pre-state.
$this->assertSame( 5, WPDO_Post_Stress_Tester::count_test_posts() );
$result = WPDO_Post_Stress_Tester::cleanup();
$this->assertSame( 5, $result['deleted_posts'] );
global $wpdb;
$post_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTS . "`" );
$meta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTMETA . "`" );
$this->assertSame( 0, $post_count );
$this->assertSame( 0, $meta_count, 'cleanup() must cascade delete postmeta.' );
}
public function test_cleanup_preserves_non_stress_posts(): void {
// Real post with prefix-collision-immune title.
global $wpdb;
$wpdb->insert( self::POSTS, array(
'ID' => 9999,
'post_title' => 'Real product not from stress',
'post_type' => 'product',
'post_content' => '',
'post_excerpt' => '',
'post_content_filtered' => '',
'to_ping' => '',
'pinged' => '',
) );
$wpdb->insert( self::POSTMETA, array(
'post_id' => 9999,
'meta_key' => '_price',
'meta_value' => '50.00',
) );
WPDO_Post_Stress_Tester::create( 'product', 3 );
$result = WPDO_Post_Stress_Tester::cleanup();
$this->assertSame( 3, $result['deleted_posts'] );
// Real post + its meta survive.
$remaining_posts = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTS . "`" );
$remaining_meta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTMETA . "`" );
$this->assertSame( 1, $remaining_posts );
$this->assertSame( 1, $remaining_meta );
}
public function test_cleanup_idempotent_on_empty_state(): void {
$result1 = WPDO_Post_Stress_Tester::cleanup();
$result2 = WPDO_Post_Stress_Tester::cleanup();
$this->assertSame( 0, $result1['deleted_posts'] );
$this->assertSame( 0, $result2['deleted_posts'] );
}
// ── v2.11.2: create_realistic() — uses wp_insert_post + update_post_meta ─
public function test_create_realistic_uses_wp_insert_post(): void {
$result = WPDO_Post_Stress_Tester::create_realistic( 'product', 3 );
$this->assertSame( 3, $result['created'] );
$this->assertSame( 'realistic', $result['mode'] ?? '' );
global $wpdb;
$count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_type = 'product'"
);
$this->assertSame( 3, $count );
}
public function test_create_realistic_writes_postmeta_via_update_post_meta(): void {
$result = WPDO_Post_Stress_Tester::create_realistic( 'product', 2 );
$this->assertSame( 2, $result['created'] );
global $wpdb;
$price_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = '_price'"
);
$this->assertSame( 2, $price_count, 'Each realistic product seeds _price.' );
}
public function test_create_realistic_uses_stress_test_prefix(): void {
WPDO_Post_Stress_Tester::create_realistic( 'product', 2 );
global $wpdb;
$prefixed = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_title LIKE %s",
WPDO_Post_Stress_Tester::TEST_POST_PREFIX . '%'
)
);
$this->assertSame( 2, $prefixed );
}
public function test_create_realistic_supports_hp_listing(): void {
$result = WPDO_Post_Stress_Tester::create_realistic( 'hp_listing', 4 );
$this->assertSame( 4, $result['created'] );
global $wpdb;
$hp_price_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = 'hp_price'"
);
$this->assertSame( 4, $hp_price_count );
}
public function test_create_realistic_rejects_unsupported_post_type(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Stress_Tester::create_realistic( 'bogus_type', 2 );
}
public function test_create_realistic_rejects_invalid_count(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Stress_Tester::create_realistic( 'product', 0 );
}
public function test_cleanup_removes_realistic_created_posts(): void {
WPDO_Post_Stress_Tester::create_realistic( 'product', 3 );
$this->assertSame( 3, WPDO_Post_Stress_Tester::count_test_posts() );
$cleanup = WPDO_Post_Stress_Tester::cleanup();
$this->assertSame( 3, $cleanup['deleted_posts'] );
$this->assertSame( 0, WPDO_Post_Stress_Tester::count_test_posts() );
}
// ── v2.11.4: state machine (start / cancel / get_state / get_progress / run_batch) ─
protected function tearDown(): void {
// Reset persisted state between state-machine tests so each test starts idle.
unset( $GLOBALS['_wp_options'][ WPDO_Post_Stress_Tester::OPT_STATE ] );
unset( $GLOBALS['_wp_transients'][ WPDO_Post_Stress_Tester::CANCEL_FLAG ] );
unset( $GLOBALS['_wp_transients']['wpdo_post_stress_pump_lock'] );
}
public function test_get_state_returns_empty_when_idle(): void {
$this->assertSame( array(), WPDO_Post_Stress_Tester::get_state() );
}
public function test_get_progress_returns_idle_when_no_state(): void {
$progress = WPDO_Post_Stress_Tester::get_progress( false );
$this->assertSame( 'idle', $progress['status'] );
$this->assertSame( 0, $progress['processed'] );
}
public function test_start_persists_state_with_running_status(): void {
$result = WPDO_Post_Stress_Tester::start( 'product', 10, 'fast', 5 );
$this->assertTrue( $result['ok'] );
$state = $result['state'];
$this->assertSame( 'running', $state['status'] );
$this->assertSame( 'product', $state['post_type'] );
$this->assertSame( 'fast', $state['mode'] );
$this->assertSame( 10, $state['target'] );
$this->assertSame( 5, $state['batch_size'] );
$this->assertSame( 0, $state['processed'] );
}
public function test_start_rejects_unsupported_post_type(): void {
$result = WPDO_Post_Stress_Tester::start( 'bogus_type', 10 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'unsupported_post_type', $result['error'] );
}
public function test_start_rejects_zero_target(): void {
$result = WPDO_Post_Stress_Tester::start( 'product', 0 );
$this->assertFalse( $result['ok'] );
}
public function test_start_rejects_excessive_target(): void {
$result = WPDO_Post_Stress_Tester::start( 'product', 100001 );
$this->assertFalse( $result['ok'] );
}
public function test_start_rejects_invalid_mode(): void {
$result = WPDO_Post_Stress_Tester::start( 'product', 10, 'turbo' );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'invalid mode', $result['error'] );
}
public function test_start_rejects_concurrent_run(): void {
WPDO_Post_Stress_Tester::start( 'product', 10 );
$result = WPDO_Post_Stress_Tester::start( 'product', 5 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'already_running', $result['error'] );
}
public function test_start_clamps_batch_size_above_max(): void {
$result = WPDO_Post_Stress_Tester::start( 'product', 10, 'fast', 5000 );
$this->assertTrue( $result['ok'] );
$this->assertSame( WPDO_Post_Stress_Tester::MAX_BATCH_SIZE, $result['state']['batch_size'] );
}
public function test_run_batch_advances_processed_count(): void {
WPDO_Post_Stress_Tester::start( 'product', 6, 'fast', 3 );
WPDO_Post_Stress_Tester::run_batch();
$progress = WPDO_Post_Stress_Tester::get_progress( false );
$this->assertSame( 3, $progress['processed'] );
$this->assertSame( 1, $progress['batches_done'] );
$this->assertSame( 'running', $progress['status'] );
WPDO_Post_Stress_Tester::run_batch();
$progress = WPDO_Post_Stress_Tester::get_progress( false );
$this->assertSame( 6, $progress['processed'] );
$this->assertSame( 'completed', $progress['status'] );
}
public function test_run_batch_creates_actual_posts(): void {
WPDO_Post_Stress_Tester::start( 'product', 4, 'fast', 4 );
WPDO_Post_Stress_Tester::run_batch();
$this->assertSame( 4, WPDO_Post_Stress_Tester::count_test_posts() );
}
public function test_run_batch_finalizes_with_benchmark(): void {
WPDO_Post_Stress_Tester::start( 'product', 2, 'fast', 2 );
WPDO_Post_Stress_Tester::run_batch();
$state = WPDO_Post_Stress_Tester::get_state();
$this->assertSame( 'completed', $state['status'] );
$this->assertIsArray( $state['benchmark'] );
$this->assertArrayHasKey( 'write', $state['benchmark'] );
$this->assertArrayHasKey( 'db_sizes', $state['benchmark'] );
$this->assertSame( 'product', $state['benchmark']['post_type'] );
}
public function test_cancel_marks_state_as_cancelled(): void {
WPDO_Post_Stress_Tester::start( 'product', 100, 'fast', 50 );
$result = WPDO_Post_Stress_Tester::cancel();
$this->assertTrue( $result['ok'] );
$this->assertSame( 'cancelled', $result['state']['status'] );
// In-flight batch run after cancel must NOT bump status back to running.
WPDO_Post_Stress_Tester::run_batch();
$state = WPDO_Post_Stress_Tester::get_state();
$this->assertSame( 'cancelled', $state['status'] );
}
public function test_cancel_returns_no_active_job_when_idle(): void {
$result = WPDO_Post_Stress_Tester::cancel();
$this->assertTrue( $result['ok'] );
$this->assertSame( 'no_active_job', $result['message'] ?? '' );
}
public function test_get_progress_includes_pct_and_eta(): void {
WPDO_Post_Stress_Tester::start( 'product', 10, 'fast', 5 );
WPDO_Post_Stress_Tester::run_batch();
$progress = WPDO_Post_Stress_Tester::get_progress( false );
$this->assertArrayHasKey( 'pct', $progress );
$this->assertArrayHasKey( 'rate_per_sec', $progress );
$this->assertArrayHasKey( 'elapsed_sec', $progress );
$this->assertArrayHasKey( 'eta_sec', $progress );
$this->assertArrayHasKey( 'test_post_count', $progress );
$this->assertSame( 50.0, $progress['pct'] ); // 5/10 = 50%
}
public function test_run_benchmark_returns_post_type_aware_query_probes(): void {
WPDO_Post_Stress_Tester::start( 'hp_listing', 4, 'fast', 4 );
WPDO_Post_Stress_Tester::run_batch();
$state = WPDO_Post_Stress_Tester::get_state();
$bench = $state['benchmark'];
$this->assertSame( 'hp_listing', $bench['post_type'] );
// query is post_type-aware; in production the flat table exists so this
// returns 3 probes (point/range/eav_baseline). In integration tests the
// flat table doesn't exist (different prefix), so we only verify the
// structure is present and post_type-aware.
$this->assertIsArray( $bench['query'] );
}
public function test_run_batch_realistic_uses_wp_insert_post(): void {
WPDO_Post_Stress_Tester::start( 'product', 3, 'realistic', 3 );
WPDO_Post_Stress_Tester::run_batch();
$state = WPDO_Post_Stress_Tester::get_state();
$this->assertSame( 'completed', $state['status'] );
$this->assertSame( 3, $state['processed'] );
$this->assertSame( 'realistic', $state['mode'] );
}
}
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Postmeta_Cleaner — wp_postmeta garbage cleanup (v2.9.0).
*
* Verifies count_garbage() and delete_garbage() against real MariaDB:
* - target=transients → meta_key LIKE '_transient_%' OR LIKE '_transient_timeout_%'
* - target=wp_old_date → meta_key = '_wp_old_date'
* - target=edit_locks → meta_key = '_edit_lock' AND lock_ts < now - 86400 (stale)
* - target=all → union of all three
*
* Requires real MariaDB (WPDO_TEST_DB_PASS env var must be set).
*/
class PostmetaCleanerIntegrationTest extends TestCase {
private const POSTMETA = 'wp_itest_postmeta';
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-postmeta-cleaner.php';
$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 COLLATE utf8mb4_unicode_ci'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function seed_postmeta( array $rows ): void {
global $wpdb;
foreach ( $rows as $row ) {
$wpdb->insert( self::POSTMETA, $row );
}
}
// ── count_garbage() ───────────────────────────────────────────────────────
public function test_count_garbage_returns_zero_for_empty_table(): void {
$counts = WPDO_Postmeta_Cleaner::count_garbage( 'all' );
$this->assertSame( 0, $counts['transients'] );
$this->assertSame( 0, $counts['wp_old_date'] );
$this->assertSame( 0, $counts['edit_locks'] );
$this->assertSame( 0, $counts['total'] );
}
public function test_count_garbage_counts_transients(): void {
$this->seed_postmeta( array(
array( 'post_id' => 1, 'meta_key' => '_transient_hp_models/listing/v1', 'meta_value' => 'a' ),
array( 'post_id' => 1, 'meta_key' => '_transient_timeout_hp_models/listing/v1', 'meta_value' => '9999' ),
array( 'post_id' => 2, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
array( 'post_id' => 2, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
) );
$counts = WPDO_Postmeta_Cleaner::count_garbage( 'transients' );
$this->assertSame( 3, $counts['transients'] );
$this->assertSame( 0, $counts['wp_old_date'] );
$this->assertSame( 0, $counts['edit_locks'] );
$this->assertSame( 3, $counts['total'] );
}
public function test_count_garbage_counts_wp_old_date(): void {
$this->seed_postmeta( array(
array( 'post_id' => 1, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ),
array( 'post_id' => 2, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-02-01' ),
array( 'post_id' => 3, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
) );
$counts = WPDO_Postmeta_Cleaner::count_garbage( 'wp_old_date' );
$this->assertSame( 0, $counts['transients'] );
$this->assertSame( 2, $counts['wp_old_date'] );
$this->assertSame( 0, $counts['edit_locks'] );
$this->assertSame( 2, $counts['total'] );
}
public function test_count_garbage_counts_only_stale_edit_locks(): void {
$now = time();
$one_day_ago = $now - 86400 - 60; // stale by 1 day + 1 min
$one_hour_ago = $now - 3600; // fresh, not stale
$five_min_ago = $now - 300; // very fresh, not stale
$this->seed_postmeta( array(
array( 'post_id' => 1, 'meta_key' => '_edit_lock', 'meta_value' => $one_day_ago . ':1' ), // stale ✓
array( 'post_id' => 2, 'meta_key' => '_edit_lock', 'meta_value' => $one_hour_ago . ':2' ), // fresh
array( 'post_id' => 3, 'meta_key' => '_edit_lock', 'meta_value' => $five_min_ago . ':3' ), // fresh
array( 'post_id' => 4, 'meta_key' => '_edit_last', 'meta_value' => '4' ), // not edit_lock
) );
$counts = WPDO_Postmeta_Cleaner::count_garbage( 'edit_locks' );
$this->assertSame( 0, $counts['transients'] );
$this->assertSame( 0, $counts['wp_old_date'] );
$this->assertSame( 1, $counts['edit_locks'], 'Only stale (>24h old) _edit_lock rows count' );
$this->assertSame( 1, $counts['total'] );
}
public function test_count_garbage_target_all_unions_all_three(): void {
$one_day_ago = time() - 86400 - 60;
$this->seed_postmeta( array(
array( 'post_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ),
array( 'post_id' => 2, 'meta_key' => '_transient_timeout_foo', 'meta_value' => '99' ),
array( 'post_id' => 3, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ),
array( 'post_id' => 4, 'meta_key' => '_edit_lock', 'meta_value' => $one_day_ago . ':1' ),
array( 'post_id' => 5, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
) );
$counts = WPDO_Postmeta_Cleaner::count_garbage( 'all' );
$this->assertSame( 2, $counts['transients'] );
$this->assertSame( 1, $counts['wp_old_date'] );
$this->assertSame( 1, $counts['edit_locks'] );
$this->assertSame( 4, $counts['total'] );
}
public function test_count_garbage_rejects_invalid_target(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Postmeta_Cleaner::count_garbage( 'bogus' );
}
// ── delete_garbage() ──────────────────────────────────────────────────────
public function test_delete_garbage_removes_only_target_rows(): void {
$this->seed_postmeta( array(
array( 'post_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ),
array( 'post_id' => 2, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ),
array( 'post_id' => 3, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
) );
$deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'transients' );
$this->assertSame( 1, $deleted['transients'] );
$this->assertSame( 0, $deleted['wp_old_date'] );
$this->assertSame( 0, $deleted['edit_locks'] );
$this->assertSame( 1, $deleted['total'] );
global $wpdb;
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::POSTMETA . '`' );
$this->assertSame( 2, $remaining, 'Non-transient rows should remain (wp_old_date + hp_price)' );
}
public function test_delete_garbage_target_all_clears_all_three(): void {
$one_day_ago = time() - 86400 - 60;
$this->seed_postmeta( array(
array( 'post_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ),
array( 'post_id' => 2, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ),
array( 'post_id' => 3, 'meta_key' => '_edit_lock', 'meta_value' => $one_day_ago . ':1' ),
array( 'post_id' => 4, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
) );
$deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'all' );
$this->assertSame( 1, $deleted['transients'] );
$this->assertSame( 1, $deleted['wp_old_date'] );
$this->assertSame( 1, $deleted['edit_locks'] );
$this->assertSame( 3, $deleted['total'] );
global $wpdb;
$remaining = $wpdb->get_results( 'SELECT meta_key FROM `' . self::POSTMETA . '`', ARRAY_A );
$this->assertCount( 1, $remaining );
$this->assertSame( 'hp_price', $remaining[0]['meta_key'] );
}
public function test_delete_garbage_does_not_touch_fresh_edit_lock(): void {
$one_hour_ago = time() - 3600;
$this->seed_postmeta( array(
array( 'post_id' => 1, 'meta_key' => '_edit_lock', 'meta_value' => $one_hour_ago . ':1' ),
) );
$deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'edit_locks' );
$this->assertSame( 0, $deleted['edit_locks'] );
global $wpdb;
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::POSTMETA . '`' );
$this->assertSame( 1, $remaining, 'Fresh edit_lock must survive cleanup' );
}
public function test_delete_garbage_rejects_invalid_target(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Postmeta_Cleaner::delete_garbage( 'bogus' );
}
}
@@ -0,0 +1,373 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Query_Router SQL generation.
*
* Verifies that pre_get_posts correctly extracts hot-field clauses from
* WP_Query meta_query, and that posts_join / posts_where / posts_groupby
* emit syntactically correct SQL fragments against the integration $wpdb.
*
* No hot table data is written — only SQL string output is asserted.
*/
class QueryRouterIntegrationTest extends TestCase {
private WPDO_Query_Router $router;
// ── Fixture lifecycle ─────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
// Register test fields in Schema Registry (in-memory singleton).
$registry = WPDO_Schema_Registry::instance();
$registry->register( 'test', [
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => 'hp_price',
'indexed' => true,
] );
$registry->register( 'test', [
'post_type' => 'hp_listing',
'meta_key' => 'hp_featured',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_featured',
'indexed' => false,
] );
}
protected function setUp(): void {
$this->router = new WPDO_Query_Router();
// Ensure the hot_hp_listing module is in a query-active state.
WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' );
}
// ── pre_get_posts ─────────────────────────────────────────────────────
public function test_pre_get_posts_extracts_hot_clause(): void {
$query = new WP_Query();
$query->set( 'post_type', 'hp_listing' );
$query->set( 'meta_query', [
[ 'key' => 'hp_price', 'value' => '100', 'compare' => '>=', 'type' => 'DECIMAL' ],
] );
$this->router->pre_get_posts( $query );
$hot = $query->get( 'wpdo_hot_clauses' );
$this->assertIsArray( $hot );
$this->assertArrayHasKey( 'hp_listing', $hot );
$this->assertCount( 1, $hot['hp_listing'] );
$this->assertSame( 'hp_price', $hot['hp_listing'][0]['column'] );
$this->assertSame( '>=', $hot['hp_listing'][0]['compare'] );
$this->assertSame( '100', $hot['hp_listing'][0]['value'] );
// Extracted clause must be removed from meta_query.
$remaining = (array) $query->get( 'meta_query' );
$this->assertEmpty( $remaining );
}
public function test_pre_get_posts_leaves_non_registered_key_in_meta_query(): void {
$query = new WP_Query();
$query->set( 'post_type', 'hp_listing' );
$query->set( 'meta_query', [
[ 'key' => 'hp_price', 'value' => '50', 'compare' => '=' ],
[ 'key' => 'custom_key', 'value' => 'abc', 'compare' => '=' ],
] );
$this->router->pre_get_posts( $query );
$hot = $query->get( 'wpdo_hot_clauses' );
$remaining = (array) $query->get( 'meta_query' );
// hp_price hot-extracted.
$this->assertArrayHasKey( 'hp_listing', $hot );
$this->assertCount( 1, $hot['hp_listing'] );
// custom_key stays in meta_query.
$this->assertCount( 1, $remaining );
$found_keys = array_column( array_values( $remaining ), 'key' );
$this->assertContains( 'custom_key', $found_keys );
}
public function test_pre_get_posts_skips_when_no_meta_query(): void {
$query = new WP_Query();
$query->set( 'post_type', 'hp_listing' );
// No meta_query set.
$this->router->pre_get_posts( $query );
$hot = $query->get( 'wpdo_hot_clauses' );
// Should not have been set at all (get returns default '').
$this->assertEmpty( $hot );
}
public function test_pre_get_posts_skips_when_no_post_type(): void {
$query = new WP_Query();
// No post_type set.
$query->set( 'meta_query', [
[ 'key' => 'hp_price', 'value' => '10', 'compare' => '=' ],
] );
$this->router->pre_get_posts( $query );
$hot = $query->get( 'wpdo_hot_clauses' );
$this->assertEmpty( $hot );
}
public function test_pre_get_posts_skips_inactive_module(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
$query = new WP_Query();
$query->set( 'post_type', 'hp_listing' );
$query->set( 'meta_query', [
[ 'key' => 'hp_price', 'value' => '99', 'compare' => '=' ],
] );
$this->router->pre_get_posts( $query );
$hot = $query->get( 'wpdo_hot_clauses' );
$remaining = (array) $query->get( 'meta_query' );
// No hot clauses extracted.
$this->assertEmpty( $hot );
// Original clause still in meta_query.
$this->assertNotEmpty( $remaining );
}
public function test_pre_get_posts_preserves_relation_in_remaining(): void {
$query = new WP_Query();
$query->set( 'post_type', 'hp_listing' );
$query->set( 'meta_query', [
'relation' => 'AND',
[ 'key' => 'hp_price', 'value' => '50', 'compare' => '>=' ],
[ 'key' => 'custom_key', 'value' => '1', 'compare' => '=' ],
] );
$this->router->pre_get_posts( $query );
$remaining = (array) $query->get( 'meta_query' );
// relation must be preserved because custom_key remains.
$this->assertArrayHasKey( 'relation', $remaining );
$this->assertSame( 'AND', $remaining['relation'] );
}
public function test_pre_get_posts_extracts_multiple_hot_fields(): void {
$query = new WP_Query();
$query->set( 'post_type', 'hp_listing' );
$query->set( 'meta_query', [
[ 'key' => 'hp_price', 'value' => '200', 'compare' => '<=' ],
[ 'key' => 'hp_featured', 'value' => '1', 'compare' => '=' ],
] );
$this->router->pre_get_posts( $query );
$hot = $query->get( 'wpdo_hot_clauses' );
$this->assertCount( 2, $hot['hp_listing'] );
$columns = array_column( $hot['hp_listing'], 'column' );
$this->assertContains( 'hp_price', $columns );
$this->assertContains( 'hp_featured', $columns );
// meta_query fully cleared.
$this->assertEmpty( (array) $query->get( 'meta_query' ) );
}
// ── posts_join ────────────────────────────────────────────────────────
public function test_posts_join_generates_left_join_sql(): void {
$query = new WP_Query();
$query->set( 'wpdo_hot_clauses', [
'hp_listing' => [
[ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '100' ],
],
] );
$join = $this->router->posts_join( '', $query );
$this->assertStringContainsString( 'LEFT JOIN', $join );
// Full physical table name.
$this->assertStringContainsString( 'wp_itest_wpdo_hot_hp_listing', $join );
// Alias.
$this->assertStringContainsString( '`wpdo_hot_hp_listing`', $join );
// posts table join key.
$this->assertStringContainsString( '`wp_itest_posts`', $join );
$this->assertStringContainsString( 'post_id', $join );
}
public function test_posts_join_passthrough_when_no_hot_clauses(): void {
$query = new WP_Query();
$original = ' LEFT JOIN wp_postmeta ON (wp_posts.ID = wp_postmeta.post_id)';
$join = $this->router->posts_join( $original, $query );
$this->assertSame( $original, $join );
}
public function test_posts_join_no_duplicate_join_for_same_alias(): void {
$query = new WP_Query();
$query->set( 'wpdo_hot_clauses', [
'hp_listing' => [
[ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '100' ],
],
] );
// Simulate alias already present in existing join string.
$existing = ' LEFT JOIN `wp_itest_wpdo_hot_hp_listing` AS `wpdo_hot_hp_listing` ON (...)';
$join = $this->router->posts_join( $existing, $query );
// Should appear exactly once.
$this->assertSame( 1, substr_count( $join, '`wpdo_hot_hp_listing`' ) );
}
// ── posts_where ───────────────────────────────────────────────────────
/** Helper: build a WP_Query with pre-set hot_clauses. */
private function query_with_clauses( array $clauses ): WP_Query {
$query = new WP_Query();
$query->set( 'wpdo_hot_clauses', [ 'hp_listing' => $clauses ] );
return $query;
}
public function test_posts_where_equality_condition(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '99.00' ],
] );
$where = $this->router->posts_where( '', $query );
$this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_price`', $where );
$this->assertStringContainsString( '=', $where );
$this->assertStringContainsString( "'99.00'", $where );
}
public function test_posts_where_numeric_type_uses_integer_placeholder(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_featured', 'compare' => '=', 'type' => 'NUMERIC', 'value' => 1 ],
] );
$where = $this->router->posts_where( '', $query );
// %d format — integer value, not quoted.
$this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_featured` = 1', $where );
}
public function test_posts_where_in_condition(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_price', 'compare' => 'IN', 'type' => 'CHAR', 'value' => [ '10.00', '20.00', '30.00' ] ],
] );
$where = $this->router->posts_where( '', $query );
$this->assertStringContainsString( 'IN', $where );
$this->assertStringContainsString( "'10.00'", $where );
$this->assertStringContainsString( "'20.00'", $where );
$this->assertStringContainsString( "'30.00'", $where );
}
public function test_posts_where_in_empty_array_generates_false_condition(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_price', 'compare' => 'IN', 'type' => 'CHAR', 'value' => [] ],
] );
$where = $this->router->posts_where( '', $query );
$this->assertStringContainsString( '1=0', $where );
}
public function test_posts_where_between_condition(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_price', 'compare' => 'BETWEEN', 'type' => 'CHAR', 'value' => [ '10.00', '50.00' ] ],
] );
$where = $this->router->posts_where( '', $query );
$this->assertStringContainsString( 'BETWEEN', $where );
$this->assertStringContainsString( "'10.00'", $where );
$this->assertStringContainsString( "'50.00'", $where );
}
public function test_posts_where_exists_generates_is_not_null(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_price', 'compare' => 'EXISTS', 'type' => 'CHAR', 'value' => '' ],
] );
$where = $this->router->posts_where( '', $query );
$this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_price` IS NOT NULL', $where );
}
public function test_posts_where_not_exists_generates_is_null(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_price', 'compare' => 'NOT EXISTS', 'type' => 'CHAR', 'value' => '' ],
] );
$where = $this->router->posts_where( '', $query );
$this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_price` IS NULL', $where );
}
public function test_posts_where_passthrough_when_no_hot_clauses(): void {
$query = new WP_Query();
$original = ' AND wp_posts.post_status = \'publish\'';
$where = $this->router->posts_where( $original, $query );
$this->assertSame( $original, $where );
}
public function test_posts_where_appends_to_existing_where(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_featured', 'compare' => '=', 'type' => 'NUMERIC', 'value' => 1 ],
] );
$existing = " AND wp_posts.post_status = 'publish'";
$where = $this->router->posts_where( $existing, $query );
$this->assertStringStartsWith( $existing, $where );
$this->assertStringContainsString( 'hp_featured', $where );
}
// ── posts_groupby ─────────────────────────────────────────────────────
public function test_posts_groupby_sets_posts_id_when_empty(): void {
$query = new WP_Query();
$query->set( 'wpdo_hot_clauses', [
'hp_listing' => [
[ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '1' ],
],
] );
$groupby = $this->router->posts_groupby( '', $query );
$this->assertStringContainsString( 'wp_itest_posts', $groupby );
$this->assertStringContainsString( 'ID', $groupby );
}
public function test_posts_groupby_preserves_existing_groupby(): void {
$query = new WP_Query();
$query->set( 'wpdo_hot_clauses', [
'hp_listing' => [
[ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '1' ],
],
] );
$existing = '`wp_itest_posts`.`ID`, `wp_itest_posts`.`post_type`';
$groupby = $this->router->posts_groupby( $existing, $query );
// Existing groupby preserved unchanged (not empty, so no override).
$this->assertSame( $existing, $groupby );
}
public function test_posts_groupby_passthrough_when_no_hot_clauses(): void {
$query = new WP_Query();
$original = '`wp_itest_posts`.`ID`';
$groupby = $this->router->posts_groupby( $original, $query );
$this->assertSame( $original, $groupby );
}
}
@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
/**
* Integration tests for WPDO_REST_API against real MariaDB.
*
* Creates a dedicated wp_itest_wpdo_hot_restapi table, seeds data,
* and exercises all four REST handlers end-to-end.
*/
use PHPUnit\Framework\TestCase;
class RestApiIntegrationTest extends TestCase {
private static WPDO_REST_API $api;
/** Table name for this test suite (avoids collisions with other tests). */
private static string $hot_table;
private static string $warm_table;
public static function setUpBeforeClass(): void {
global $wpdb;
self::$api = new WPDO_REST_API();
self::$hot_table = $wpdb->prefix . 'wpdo_hot_restapi';
self::$warm_table = $wpdb->prefix . 'wpdo_warm';
// Register fields for the fake 'restapi' post type.
$registry = WPDO_Schema_Registry::instance();
$registry->register( 'integration_rest', [
'post_type' => 'restapi',
'meta_key' => 'rp_price',
'zone' => 'hot',
'column' => 'rp_price',
'type' => 'decimal',
] );
$registry->register( 'integration_rest', [
'post_type' => 'restapi',
'meta_key' => 'rp_featured',
'zone' => 'hot',
'column' => 'rp_featured',
'type' => 'tinyint',
] );
$registry->register( 'integration_rest', [
'post_type' => 'restapi',
'meta_key' => 'rp_description',
'zone' => 'cold',
] );
// Create hot table.
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `" . self::$hot_table . "` (
post_id BIGINT UNSIGNED NOT NULL,
rp_price DECIMAL(10,2) DEFAULT NULL,
rp_featured TINYINT(1) DEFAULT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Create warm table (needed by WPDO_Listing_Stats::get_view_count).
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `" . self::$warm_table . "` (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
meta_key VARCHAR(255) NOT NULL,
meta_value LONGTEXT,
expires_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY post_meta (post_id, meta_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Seed 5 rows.
for ( $i = 1; $i <= 5; $i++ ) {
$price = $i * 100;
$featured = $i % 2;
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES ($i, $price, $featured)" );
}
// Set module to cutover so REST API reads from Zone A.
WPDO_Feature_Flags::set( 'hot_restapi', 'cutover' );
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$hot_table . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
WPDO_Feature_Flags::reset( 'hot_restapi' );
}
protected function setUp(): void {
$GLOBALS['_wp_cache'] = [];
$GLOBALS['_wp_post_types'] = [];
$GLOBALS['_wp_postmeta'] = [];
$GLOBALS['_wp_current_user_can'] = [];
$GLOBALS['_wp_valid_nonces'] = [];
$GLOBALS['_wp_transients'] = []; // Reset rate-limit transients between tests.
// Note: do NOT reset _wp_options here — Feature Flags state is stored there.
// Invalidate Feature Flags static request cache so each test reads fresh.
$ref = new ReflectionClass( WPDO_Feature_Flags::class );
$prop = $ref->getProperty( 'cache' );
$prop->setAccessible( true );
$prop->setValue( null, null );
}
// ── GET /listings (Zone A path) ──────────────────────────────────────────
public function test_listings_returns_all_rows(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'per_page', 10 );
$response = self::$api->get_listings( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertCount( 5, $data );
$this->assertSame( '5', $response->get_headers()['X-WP-Total'] );
}
public function test_listings_returns_correct_fields(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'per_page', 1 );
$req->set_param( 'orderby', 'post_id' );
$req->set_param( 'order', 'ASC' );
$response = self::$api->get_listings( $req );
$data = $response->get_data();
$this->assertSame( 1, $data[0]['id'] );
$this->assertSame( 'restapi', $data[0]['post_type'] );
$this->assertArrayHasKey( 'rp_price', $data[0] );
$this->assertArrayHasKey( 'rp_featured', $data[0] );
$this->assertArrayNotHasKey( 'post_id', $data[0] );
$this->assertArrayNotHasKey( 'updated_at', $data[0] );
}
public function test_listings_pagination(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'per_page', 2 );
$req->set_param( 'page', 2 );
$req->set_param( 'orderby', 'post_id' );
$req->set_param( 'order', 'ASC' );
$response = self::$api->get_listings( $req );
$data = $response->get_data();
$this->assertCount( 2, $data );
$this->assertSame( 3, $data[0]['id'] ); // page 2 offset 2 → post_id 3
$this->assertSame( '3', $response->get_headers()['X-WP-TotalPages'] );
}
public function test_listings_per_page_clamped_to_max_100(): void {
// PR-0 R-4: defense-in-depth — per_page=99999 must clamp to 100, not DoS the DB.
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'per_page', 99999 );
$response = self::$api->get_listings( $req );
// Should return at most 100 items (real dataset is 5 — capped by total).
$data = $response->get_data();
$this->assertLessThanOrEqual( 100, count( $data ) );
}
public function test_listings_per_page_max_filter_overridable(): void {
// PR-0 R-4: site owners can lower the cap via wpdo_rest_max_per_page filter.
// We can't fully test add_filter() in this stubbed env, but we verify the
// constant value is correctly read in the code path (above test exercises 100).
$this->assertTrue( true );
}
public function test_listings_filter_price_min(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'rp_price_min', 300 );
$response = self::$api->get_listings( $req );
$data = $response->get_data();
// Prices are 100,200,300,400,500 → ≥300 = 3 rows.
$this->assertSame( '3', $response->get_headers()['X-WP-Total'] );
foreach ( $data as $item ) {
$this->assertGreaterThanOrEqual( 300.0, (float) $item['rp_price'] );
}
}
public function test_listings_filter_price_range(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'rp_price_min', 200 );
$req->set_param( 'rp_price_max', 400 );
$response = self::$api->get_listings( $req );
$this->assertSame( '3', $response->get_headers()['X-WP-Total'] );
}
public function test_listings_filter_exact_value(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'rp_featured', 1 );
$response = self::$api->get_listings( $req );
$data = $response->get_data();
// featured=1 for post_id 1,3,5 → 3 rows.
$this->assertSame( '3', $response->get_headers()['X-WP-Total'] );
foreach ( $data as $item ) {
$this->assertSame( '1', (string) $item['rp_featured'] );
}
}
public function test_listings_order_asc(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'orderby', 'rp_price' );
$req->set_param( 'order', 'ASC' );
$req->set_param( 'per_page', 5 );
$response = self::$api->get_listings( $req );
$data = $response->get_data();
$prices = array_column( $data, 'rp_price' );
$sorted = $prices;
sort( $sorted );
$this->assertSame( $sorted, $prices );
}
// ── GET /listings/{id} ───────────────────────────────────────────────────
public function test_get_listing_404_for_unknown_post(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/9999' );
$req->set_param( 'id', 9999 );
$response = self::$api->get_listing( $req );
$this->assertSame( 404, $response->get_status() );
}
public function test_get_listing_merges_hot_and_postmeta_cold(): void {
// post_id 1 is in hot table (rp_price=100); cold zone idle → postmeta fallback.
$GLOBALS['_wp_post_types'][1] = 'restapi';
$GLOBALS['_wp_postmeta'][1]['rp_description'] = 'Integration test';
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/1' );
$req->set_param( 'id', 1 );
$response = self::$api->get_listing( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertSame( 1, $data['id'] );
$this->assertSame( '100.00', $data['rp_price'] );
$this->assertSame( 'Integration test', $data['rp_description'] );
}
// ── GET /stats/{id} ──────────────────────────────────────────────────────
public function test_get_stats_returns_zero_for_unknown_post(): void {
$GLOBALS['_wp_post_types'][77] = 'restapi';
$req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/77' );
$req->set_param( 'id', 77 );
$response = self::$api->get_stats( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertSame( 77, $data['post_id'] );
$this->assertSame( 0, $data['view_count'] );
}
// ── GET /status ──────────────────────────────────────────────────────────
public function test_get_status_requires_manage_options(): void {
$GLOBALS['_wp_current_user_can']['manage_options'] = false;
$this->assertFalse( self::$api->require_manage_options() );
}
public function test_get_status_returns_correct_engine(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/status' );
$response = self::$api->get_status( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertSame( 'mysql', $data['engine'] );
$this->assertArrayHasKey( 'modules', $data );
$this->assertSame( 'cutover', $data['modules']['hot_restapi'] );
}
// ── POST /listings/{id}/view ──────────────────────────────────────────────
public function test_post_view_403_without_nonce(): void {
$GLOBALS['_wp_post_types'][1] = 'restapi';
$GLOBALS['_wp_valid_nonces'] = [];
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/1/view' );
$req->set_param( 'id', 1 );
// No nonce.
$response = self::$api->post_view( $req );
$this->assertSame( 403, $response->get_status() );
}
public function test_post_view_404_for_unknown_post(): void {
$nonce = wp_create_nonce( 'wp_rest' );
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/8888/view' );
$req->set_param( 'id', 8888 );
$req->set_header( 'X-WP-Nonce', $nonce );
$response = self::$api->post_view( $req );
$this->assertSame( 404, $response->get_status() );
}
}
@@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Sync_Bridge entity-bridge guard (v2.9.2).
*
* Verifies that when WPDO_Entity_Registry has registered a meta_key for
* entity_type=post AND post mode is dual_write or higher, Sync_Bridge
* skips its zone write so the same value isn't written to two flat tables.
*
* This is the central correctness guarantee for v2.9.2 — without it, the
* 5 keys overlapping between Schema_Registry hot zone and the new post
* Entity Registry (hp_price, hp_featured, hp_verified, _price, _stock)
* would receive triple writes (zone + entity flat + wp_postmeta).
*
* Behavior matrix:
* post mode = disabled → Sync_Bridge writes zone (legacy unchanged)
* post mode = dual_write+ + key in Entity_Registry → Sync_Bridge skips zone
* post mode = dual_write+ + key NOT in Entity_Registry → Sync_Bridge writes zone (back-compat)
*/
class SyncBridgeEntityGuardTest extends TestCase {
private const POST_TYPE = 'hp_listing';
private const TABLE = 'wp_itest_wpdo_hot_hp_listing';
private const MODULE = 'hot_hp_listing';
private const ENTITY_KEY = 'hp_price'; // overlaps Entity_Registry hp_listing_core
private const ZONE_ONLY_KEY = 'hp_legacy_only'; // only in Schema_Registry, not Entity_Registry
private WPDO_Sync_Bridge $bridge;
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
// Load Entity_Registry chain (interface → adapter → registry → mode-manager).
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/interface-entity-adapter.php';
}
if ( ! class_exists( 'WPDO_Entity_Registry' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/engine/class-tmdo-entity-registry.php';
}
if ( ! class_exists( 'WPDO_Mode_Manager' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/engine/class-tmdo-mode-manager.php';
}
if ( ! class_exists( 'WPDO_Adapter_Post' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/class-tmdo-adapter-post.php';
}
if ( ! class_exists( 'WPDO_Post_Fields' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-post-fields.php';
}
// Hot zone test table (legacy Sync_Bridge target).
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`hp_price` decimal(10,2) DEFAULT NULL,
`hp_legacy_only` varchar(255) DEFAULT NULL,
`updated_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
PRIMARY KEY (`id`),
UNIQUE KEY `post_id` (`post_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' );
$wpdb->query(
'CREATE TABLE `wp_itest_wpdo_errors` (
`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'
);
// Reset Schema_Registry singleton + register both keys (one will overlap with Entity_Registry).
$ref = new ReflectionClass( WPDO_Schema_Registry::class );
$inst = $ref->getProperty( 'instance' );
$inst->setAccessible( true );
$inst->setValue( null, null );
WPDO_Schema_Registry::instance()->register( 'test', array(
'post_type' => self::POST_TYPE,
'meta_key' => self::ENTITY_KEY,
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => self::ENTITY_KEY,
'indexed' => false,
) );
WPDO_Schema_Registry::instance()->register( 'test', array(
'post_type' => self::POST_TYPE,
'meta_key' => self::ZONE_ONLY_KEY,
'zone' => 'hot',
'data_type' => 'varchar(255) DEFAULT NULL',
'column' => self::ZONE_ONLY_KEY,
'indexed' => false,
) );
// Register post adapter + post-fields groups (puts hp_price into Entity_Registry).
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::TABLE . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' );
// Reset Mode_Manager cache to prevent post=dual_write leaking into
// later tests that share the same PHP process (e.g. SyncBridgeIntegrationTest
// which uses 'hp_price' as a generic test field — that key is in the post
// Entity_Registry once we've registered it here, so the guard would fire
// in those tests' assertions if mode is still cached as dual_write).
$ref = new ReflectionClass( WPDO_Mode_Manager::class );
$cache = $ref->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, null );
// Also reset Entity_Registry so the registered post groups don't leak.
WPDO_Entity_Registry::init();
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
$GLOBALS['_wp_options'] = array();
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
// Reset Sync_Bridge state.
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
$cache = $ref->getProperty( 'field_cache' );
$cache->setAccessible( true );
$cache->setValue( null, array() );
$bypass = $ref->getProperty( 'bypassing' );
$bypass->setAccessible( true );
$bypass->setValue( null, false );
// Reset Mode_Manager cache to default (post=disabled).
// Tests that need dual_write override via set_post_mode() helper below,
// which writes the cache directly (avoiding Cache_Orchestrator dep).
self::set_post_mode( 'disabled' );
// Seed post-type lookup.
$GLOBALS['_wp_post_types'] = array();
for ( $i = 1; $i <= 20; $i++ ) {
$GLOBALS['_wp_post_types'][ $i ] = self::POST_TYPE;
}
$GLOBALS['_wp_cache'] = array();
$this->bridge = new WPDO_Sync_Bridge();
}
/**
* Set Mode_Manager post mode by writing the static cache directly,
* bypassing set() which has a hard dep on WPDO_Cache_Orchestrator
* (out of scope for this guard test).
*/
private static function set_post_mode( string $mode ): void {
$ref = new ReflectionClass( WPDO_Mode_Manager::class );
$cache = $ref->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, array(
'post' => $mode,
'user' => 'aeav_only', // user mode frozen — must not change
'term' => 'dual_write',
'comment' => 'dual_write',
) );
}
// ── Tests ─────────────────────────────────────────────────────────────────
/**
* Baseline: post mode = disabled (default) — Sync_Bridge MUST still write zone.
* This guarantees v2.9.1 → v2.9.2 upgrade is zero-impact for users who
* haven't opted in to Entity Bridge post mode.
*/
public function test_zone_write_unchanged_when_post_mode_disabled(): void {
// post mode defaults to disabled — Mode_Manager reads from option.
$this->bridge->intercept_update( null, 1, self::ENTITY_KEY, '199.99', '' );
$val = WPDO_Zone_Hot::get( 1, self::POST_TYPE, self::ENTITY_KEY );
$this->assertSame(
'199.99',
$val,
'mode=disabled: Sync_Bridge must continue writing zone (legacy back-compat).'
);
}
/**
* Guard: post mode = dual_write + key registered in Entity_Registry
* → Sync_Bridge skips zone write (Entity Bridge will handle it).
*/
public function test_zone_skipped_when_post_mode_dual_write_and_key_in_entity_registry(): void {
self::set_post_mode( 'dual_write' );
$this->bridge->intercept_update( null, 2, self::ENTITY_KEY, '299.99', '' );
$val = WPDO_Zone_Hot::get( 2, self::POST_TYPE, self::ENTITY_KEY );
$this->assertNull(
$val,
'mode=dual_write + Entity_Registry has key: Sync_Bridge MUST skip zone write to avoid double-write.'
);
}
/**
* Back-compat: post mode = dual_write + key NOT in Entity_Registry
* → Sync_Bridge still writes zone (only Entity_Registry-managed keys are skipped).
*/
public function test_zone_write_continues_for_zone_only_key_when_post_mode_dual_write(): void {
self::set_post_mode( 'dual_write' );
// hp_legacy_only is in Schema_Registry only — not in Entity_Registry.
$this->bridge->intercept_update( null, 3, self::ZONE_ONLY_KEY, 'legacy_value', '' );
$val = WPDO_Zone_Hot::get( 3, self::POST_TYPE, self::ZONE_ONLY_KEY );
$this->assertSame(
'legacy_value',
$val,
'mode=dual_write but key not in Entity_Registry: Sync_Bridge must keep writing zone (back-compat).'
);
}
/**
* The intercept_update return value must remain null in all branches —
* we never short-circuit WP native postmeta in v2.9.2 (still dual_write
* w.r.t. wp_postmeta; cutover comes in v2.9.5).
*/
public function test_intercept_returns_null_regardless_of_guard(): void {
self::set_post_mode( 'dual_write' );
$result_skipped = $this->bridge->intercept_update( null, 4, self::ENTITY_KEY, '50.00', '' );
$result_written = $this->bridge->intercept_update( null, 5, self::ZONE_ONLY_KEY, 'x', '' );
$this->assertNull( $result_skipped, 'Guard branch must still return null.' );
$this->assertNull( $result_written, 'Non-guard branch must still return null.' );
}
/**
* intercept_add must apply the same guard.
*/
public function test_add_zone_skipped_when_entity_registry_owns_key(): void {
self::set_post_mode( 'dual_write' );
$this->bridge->intercept_add( null, 6, self::ENTITY_KEY, '99.99', true );
$val = WPDO_Zone_Hot::get( 6, self::POST_TYPE, self::ENTITY_KEY );
$this->assertNull(
$val,
'intercept_add must apply the Entity_Registry guard symmetrically with intercept_update.'
);
}
}
@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Sync_Bridge against real MariaDB.
*
* Validates the full dual-write path:
* intercept_update / intercept_add → Zone Hot table written
* intercept_get → Zone Hot table read when module in read-custom state
* intercept_delete → Zone Hot column zeroed out
*
* Uses a dedicated test post type `test_post` and table `wp_itest_wpdo_hot_test_post`.
* get_post_type() is driven by $GLOBALS['_wp_post_types'] set in each test.
*/
class SyncBridgeIntegrationTest extends TestCase {
private const POST_TYPE = 'test_post';
private const TABLE = 'wp_itest_wpdo_hot_test_post';
private const MODULE = 'hot_test_post'; // WPDO_Sync_Bridge::get_zone_module('hot', 'test_post')
private const FIELD = 'hp_price';
private WPDO_Sync_Bridge $bridge;
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
// DROP + CREATE ensures clean schema even after interrupted prior runs.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`hp_price` decimal(10,2) DEFAULT NULL,
`updated_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
PRIMARY KEY (`id`),
UNIQUE KEY `post_id` (`post_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
// Create the errors log table so WPDO_Logger::error() can write to it.
$wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' );
$wpdb->query(
'CREATE TABLE `wp_itest_wpdo_errors` (
`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'
);
// Reset and populate the Schema Registry singleton.
$ref = new ReflectionClass( WPDO_Schema_Registry::class );
$inst = $ref->getProperty( 'instance' );
$inst->setAccessible( true );
$inst->setValue( null, null );
WPDO_Schema_Registry::instance()->register( 'test_provider', [
'post_type' => self::POST_TYPE,
'meta_key' => self::FIELD,
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => self::FIELD,
'indexed' => false,
] );
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' );
}
protected function setUp(): void {
global $wpdb;
// Wipe data before each test.
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
// Reset FeatureFlags (clears option + static cache).
$GLOBALS['_wp_options'] = [];
WPDO_Feature_Flags::set( self::MODULE, 'idle' );
// Reset SyncBridge request-level field cache.
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
$cache = $ref->getProperty( 'field_cache' );
$cache->setAccessible( true );
$cache->setValue( null, [] );
// Reset $bypassing flag.
$bypass = $ref->getProperty( 'bypassing' );
$bypass->setAccessible( true );
$bypass->setValue( null, false );
// Seed post-type lookup.
$GLOBALS['_wp_post_types'] = [];
for ( $i = 1; $i <= 20; $i++ ) {
$GLOBALS['_wp_post_types'][ $i ] = self::POST_TYPE;
}
// Object cache reset.
$GLOBALS['_wp_cache'] = [];
$this->bridge = new WPDO_Sync_Bridge();
}
// ── intercept_update ─────────────────────────────────────────────────────
public function test_update_dual_write_writes_value_to_hot_table(): void {
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
$this->bridge->intercept_update( null, 1, self::FIELD, '199.99', '' );
$val = WPDO_Zone_Hot::get( 1, self::POST_TYPE, self::FIELD );
$this->assertSame( '199.99', $val );
}
public function test_update_idle_does_not_write_to_hot_table(): void {
// Module stays in 'idle' — is_write_active() returns false.
$this->bridge->intercept_update( null, 2, self::FIELD, '50.00', '' );
$val = WPDO_Zone_Hot::get( 2, self::POST_TYPE, self::FIELD );
$this->assertNull( $val );
}
public function test_update_always_returns_null_to_allow_native_write(): void {
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
$result = $this->bridge->intercept_update( null, 3, self::FIELD, '100.00', '' );
// Must return null (not short-circuit) so WordPress still writes postmeta.
$this->assertNull( $result );
}
public function test_update_skips_unregistered_meta_key(): void {
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
// 'hp_unregistered' is not in Schema Registry.
$this->bridge->intercept_update( null, 4, 'hp_unregistered', '42.00', '' );
// Hot table for test_post should still be empty.
$val = WPDO_Zone_Hot::get( 4, self::POST_TYPE, self::FIELD );
$this->assertNull( $val );
}
public function test_update_skips_when_post_type_unknown(): void {
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
// post_id 999 not seeded in _wp_post_types → get_post_type() returns false.
$this->bridge->intercept_update( null, 999, self::FIELD, '77.00', '' );
// Nothing should have been written (table doesn't have post 999).
$val = WPDO_Zone_Hot::get( 999, self::POST_TYPE, self::FIELD );
$this->assertNull( $val );
}
// ── intercept_add ────────────────────────────────────────────────────────
public function test_add_dual_write_writes_value_to_hot_table(): void {
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
$this->bridge->intercept_add( null, 5, self::FIELD, '299.00', true );
$val = WPDO_Zone_Hot::get( 5, self::POST_TYPE, self::FIELD );
$this->assertSame( '299.00', $val );
}
public function test_add_returns_null_to_allow_native_write(): void {
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
$result = $this->bridge->intercept_add( null, 6, self::FIELD, '10.00', false );
$this->assertNull( $result );
}
// ── intercept_get ────────────────────────────────────────────────────────
public function test_get_cutover_returns_zone_value_wrapped_in_array(): void {
// Write directly to hot table, then verify intercept_get reads it back.
WPDO_Zone_Hot::set( 7, self::POST_TYPE, self::FIELD, '500.00' );
WPDO_Feature_Flags::set( self::MODULE, 'cutover' );
$result = $this->bridge->intercept_get( null, 7, self::FIELD, true );
// SyncBridge wraps value in array so WP can unwrap correctly.
$this->assertIsArray( $result );
$this->assertSame( '500.00', $result[0] );
}
public function test_get_dual_write_returns_null_passthrough(): void {
WPDO_Zone_Hot::set( 8, self::POST_TYPE, self::FIELD, '123.00' );
// dual_write is NOT a read-custom state.
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
$result = $this->bridge->intercept_get( null, 8, self::FIELD, true );
// Should pass through (return null) so WP reads from postmeta.
$this->assertNull( $result );
}
public function test_get_returns_null_when_no_zone_row(): void {
// cutover state but no row in hot table.
WPDO_Feature_Flags::set( self::MODULE, 'cutover' );
$result = $this->bridge->intercept_get( null, 9, self::FIELD, true );
$this->assertNull( $result );
}
public function test_get_returns_null_for_empty_meta_key(): void {
WPDO_Feature_Flags::set( self::MODULE, 'cutover' );
// Empty meta_key means "get all meta" — bridge should pass through.
$result = $this->bridge->intercept_get( null, 10, '', true );
$this->assertNull( $result );
}
// ── intercept_delete ─────────────────────────────────────────────────────
public function test_delete_zeros_out_hot_column(): void {
WPDO_Zone_Hot::set( 11, self::POST_TYPE, self::FIELD, '999.00' );
$this->assertSame( '999.00', WPDO_Zone_Hot::get( 11, self::POST_TYPE, self::FIELD ) );
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
$this->bridge->intercept_delete( [ 1 ], 11, self::FIELD, '999.00' );
// delete_from_zone calls Zone_Hot::set(post_id, post_type, column, null).
$val = WPDO_Zone_Hot::get( 11, self::POST_TYPE, self::FIELD );
$this->assertNull( $val );
}
// ── $bypassing flag ───────────────────────────────────────────────────────
public function test_bypass_flag_prevents_intercept_get(): void {
WPDO_Zone_Hot::set( 12, self::POST_TYPE, self::FIELD, '777.00' );
WPDO_Feature_Flags::set( self::MODULE, 'cutover' );
// Simulate internal call (e.g. migration reading postmeta).
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
$bypass = $ref->getProperty( 'bypassing' );
$bypass->setAccessible( true );
$bypass->setValue( null, true );
$result = $this->bridge->intercept_get( null, 12, self::FIELD, true );
// Should pass through immediately, ignoring zone.
$this->assertNull( $result );
}
public function test_bypass_flag_prevents_intercept_update(): void {
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
$bypass = $ref->getProperty( 'bypassing' );
$bypass->setAccessible( true );
$bypass->setValue( null, true );
$this->bridge->intercept_update( null, 13, self::FIELD, '888.00', '' );
// bypassing = true → no write to hot table.
$val = WPDO_Zone_Hot::get( 13, self::POST_TYPE, self::FIELD );
$this->assertNull( $val );
}
}
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Term_Comment_Garbage_Filter (v2.12.1).
*
* Tests the metadata filter callbacks directly with synthetic args.
* Live WP add_filter / get_term_meta wiring is exercised by a dev10
* smoke test (see CHANGELOG); these unit-style integration tests focus
* on the pure logic of pattern matching + counter behavior.
*/
class TermCommentGarbageFilterTest extends TestCase {
public static function setUpBeforeClass(): void {
if ( ! class_exists( 'WPDO_Term_Comment_Garbage_Filter' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-term-comment-garbage-filter.php';
}
}
protected function setUp(): void {
// Reset wp_options state for clean per-test counter behavior.
$GLOBALS['_wp_options'] = array();
update_option( WPDO_Term_Comment_Garbage_Filter::OPT_ENABLED, '1' );
}
// ── is_shared_garbage_key ────────────────────────────────────────────────
public function test_is_shared_garbage_key_matches_wxr_import(): void {
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_import_user' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_import_post' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_import_term' ) );
}
public function test_is_shared_garbage_key_matches_2meet_demo(): void {
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_2meet_demo_music' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_2meet_demo_adv' ) );
}
public function test_is_shared_garbage_key_rejects_legitimate_keys(): void {
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'hp_sort_order' ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'hp_default' ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'hp_rating' ) );
}
public function test_is_shared_garbage_key_rejects_partial_match(): void {
// Substring matches should NOT trigger.
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'something_wxr_import_' ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_imp' ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_2meet_demos' ) );
}
public function test_is_shared_garbage_key_rejects_non_string(): void {
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( null ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 123 ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( array() ) );
}
// ── is_comment_orphan_key ────────────────────────────────────────────────
public function test_is_comment_orphan_key_matches_post_domain_keys(): void {
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_price' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_status' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_featured' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_verified' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_view_count' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_thumbnail_id' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_edit_lock' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_edit_last' ) );
}
public function test_is_comment_orphan_key_rejects_legitimate_comment_keys(): void {
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( 'hp_rating' ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( 'note_group' ) );
}
public function test_is_comment_orphan_key_requires_exact_match(): void {
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_price_extended' ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_pric' ) );
}
// ── on_term_write callback ───────────────────────────────────────────────
public function test_on_term_write_drops_garbage_keys(): void {
$result = WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_wxr_import_user', 'val', false );
$this->assertTrue( $result, 'garbage write must short-circuit (return true)' );
}
public function test_on_term_write_passes_through_legitimate_keys(): void {
$result = WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, 'hp_sort_order', '5', false );
$this->assertNull( $result, 'legitimate write must fall through (return null)' );
}
public function test_on_term_write_does_not_apply_comment_orphan_rules(): void {
// _hp_price is comment-only orphan; for term writes it must pass through
$result = WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_hp_price', '99', false );
$this->assertNull( $result );
}
// ── on_comment_write callback ────────────────────────────────────────────
public function test_on_comment_write_drops_shared_garbage(): void {
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_wxr_import_user', 'a', false ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_2meet_demo_music', '1', false ) );
}
public function test_on_comment_write_drops_orphan_post_meta(): void {
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_hp_price', '99', false ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_thumbnail_id', '50', false ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_edit_lock', '111:1', false ) );
}
public function test_on_comment_write_passes_through_legitimate_keys(): void {
$this->assertNull( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, 'hp_rating', '5', false ) );
$this->assertNull( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, 'note_group', 'foo', false ) );
}
// ── 24h drop counter ────────────────────────────────────────────────────
public function test_drop_counter_starts_at_zero(): void {
$this->assertSame( 0, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
}
public function test_drop_counter_increments_on_each_drop(): void {
WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_wxr_import_user', 'a', false );
WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 2, '_2meet_demo_music', '1', false );
WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 3, '_hp_price', '99', false );
$this->assertSame( 3, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
}
public function test_drop_counter_does_not_increment_on_legitimate_writes(): void {
WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, 'hp_sort_order', '5', false );
WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 2, 'hp_rating', '5', false );
$this->assertSame( 0, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
}
public function test_drop_counter_resets_after_24h(): void {
// Simulate counter from 25h ago
update_option( WPDO_Term_Comment_Garbage_Filter::OPT_DROPPED_COUNT, 100 );
update_option( WPDO_Term_Comment_Garbage_Filter::OPT_DROPPED_RESET_AT, time() - 25 * HOUR_IN_SECONDS );
// get_drop_count_24h returns 0 (auto-reset semantics)
$this->assertSame( 0, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
// First new drop after expiry resets counter to 1
WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_wxr_import_user', 'a', false );
$this->assertSame( 1, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
}
// ── is_enabled toggle ───────────────────────────────────────────────────
public function test_is_enabled_defaults_true(): void {
delete_option( WPDO_Term_Comment_Garbage_Filter::OPT_ENABLED );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_enabled() );
}
public function test_is_enabled_respects_zero_value(): void {
update_option( WPDO_Term_Comment_Garbage_Filter::OPT_ENABLED, '0' );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_enabled() );
}
}
@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Term_Comment_Misc_Bucket (v2.12.4 Phase 4).
*
* Verifies the priority-99 catch-all behavior:
* - on_*_read returns $pre when $pre !== null (preserves earlier filter result)
* - on_*_write returns $check when $check !== null (preserves earlier short-circuit)
* - Otherwise: write/read goes to wp_wpdo_term_misc / wp_wpdo_comment_misc
* - UPSERT semantics on PRIMARY KEY (entity_id, meta_key)
* - Round-trip: write → read returns same value
* - Delete clears the row
*/
class TermCommentMiscBucketTest extends TestCase {
private const TERM_MISC = 'wp_itest_wpdo_term_misc';
private const COMMENT_MISC = 'wp_itest_wpdo_comment_misc';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Term_Comment_Misc_Bucket' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-term-comment-misc-bucket.php';
}
// Override $wpdb->prefix's resolution by creating tables under the
// itest prefix and shadowing term_table()/comment_table() via $wpdb->prefix.
// $wpdb->prefix is 'wp_itest_' in tests so wpdo_term_misc resolves to wp_itest_wpdo_term_misc.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_MISC . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TERM_MISC . '` (
term_id bigint(20) unsigned NOT NULL,
meta_key varchar(191) NOT NULL,
meta_value longtext,
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (term_id, meta_key),
KEY meta_key (meta_key)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENT_MISC . '`' );
$wpdb->query(
'CREATE TABLE `' . self::COMMENT_MISC . '` (
comment_id bigint(20) unsigned NOT NULL,
meta_key varchar(191) NOT NULL,
meta_value longtext,
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (comment_id, meta_key),
KEY meta_key (meta_key)
) DEFAULT CHARACTER SET utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_MISC . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENT_MISC . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TERM_MISC . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::COMMENT_MISC . '`' );
}
// ── $pre preservation contract (priority chain integrity) ────────────────
public function test_on_term_read_preserves_non_null_pre(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_term_read( array( 'managed_value' ), 1, 'any_key', true );
$this->assertSame( array( 'managed_value' ), $result );
}
public function test_on_term_add_preserves_non_null_check(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_term_add( true, 1, 'any_key', 'value', false );
$this->assertTrue( $result, 'Must preserve $check=true (someone else handled write)' );
}
public function test_on_term_update_preserves_non_null_check(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_term_update( true, 1, 'any_key', 'value', '' );
$this->assertTrue( $result );
}
public function test_on_term_delete_preserves_non_null_check(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_term_delete( true, 1, 'any_key', '', false );
$this->assertTrue( $result );
}
public function test_comment_callbacks_preserve_non_null_check(): void {
$this->assertTrue( WPDO_Term_Comment_Misc_Bucket::on_comment_add( true, 1, 'any', 'v', false ) );
$this->assertTrue( WPDO_Term_Comment_Misc_Bucket::on_comment_update( true, 1, 'any', 'v', '' ) );
$this->assertTrue( WPDO_Term_Comment_Misc_Bucket::on_comment_delete( true, 1, 'any', '', false ) );
$this->assertSame( array( 'v' ), WPDO_Term_Comment_Misc_Bucket::on_comment_read( array( 'v' ), 1, 'any', true ) );
}
// ── Catch-all behavior when $check === null ─────────────────────────────
public function test_on_term_add_writes_to_misc_table_when_unhandled(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_term_add( null, 5, 'note_group', 'foo', false );
$this->assertTrue( $result, 'Must short-circuit (return true) after writing' );
global $wpdb;
$value = $wpdb->get_var(
$wpdb->prepare(
'SELECT meta_value FROM `' . self::TERM_MISC . '` WHERE term_id = %d AND meta_key = %s',
5,
'note_group'
)
);
$this->assertSame( 'foo', $value );
}
public function test_on_term_read_returns_value_from_misc_table_when_unhandled(): void {
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 7, 'unknown_key', 'bar', '' );
$result = WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 7, 'unknown_key', true );
$this->assertSame( array( 'bar' ), $result );
}
public function test_on_term_read_returns_pre_on_cache_miss(): void {
// No prior write — read should fall through (return $pre = null, letting WP query DB).
$result = WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 999, 'never_written', true );
$this->assertNull( $result );
}
public function test_upsert_replaces_existing_value(): void {
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'first', '' );
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'second', '' );
$result = WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 5, 'k', true );
$this->assertSame( array( 'second' ), $result );
// Ensure exactly one row (composite PK enforces this).
global $wpdb;
$count = (int) $wpdb->get_var(
'SELECT COUNT(*) FROM `' . self::TERM_MISC . "` WHERE term_id = 5 AND meta_key = 'k'"
);
$this->assertSame( 1, $count );
}
public function test_on_term_delete_removes_row(): void {
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'v', '' );
$this->assertSame( array( 'v' ), WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 5, 'k', true ) );
WPDO_Term_Comment_Misc_Bucket::on_term_delete( null, 5, 'k', '', false );
$this->assertNull( WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 5, 'k', true ) );
}
// ── Comment side ─────────────────────────────────────────────────────────
public function test_on_comment_add_writes_to_misc_table(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_comment_add( null, 8, 'note_group', 'baz', false );
$this->assertTrue( $result );
global $wpdb;
$value = $wpdb->get_var(
$wpdb->prepare(
'SELECT meta_value FROM `' . self::COMMENT_MISC . '` WHERE comment_id = %d AND meta_key = %s',
8,
'note_group'
)
);
$this->assertSame( 'baz', $value );
}
public function test_term_writes_do_not_pollute_comment_table(): void {
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'term_val', '' );
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENT_MISC . '`' );
$this->assertSame( 0, $count, 'Term writes must not appear in comment misc table' );
}
// ── Empty meta_key guard ────────────────────────────────────────────────
public function test_empty_meta_key_returns_check_unchanged(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_term_add( null, 5, '', 'value', false );
$this->assertNull( $result, 'Empty meta_key must not be written' );
}
public function test_non_string_meta_key_returns_check_unchanged(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 123, 'value', '' );
$this->assertNull( $result );
}
// ── is_enabled toggle ───────────────────────────────────────────────────
public function test_is_enabled_defaults_true(): void {
delete_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED );
$this->assertTrue( WPDO_Term_Comment_Misc_Bucket::is_enabled() );
}
public function test_is_enabled_respects_zero_value(): void {
update_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED, '0' );
$this->assertFalse( WPDO_Term_Comment_Misc_Bucket::is_enabled() );
delete_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED );
}
public function test_count_rows_returns_actual_count(): void {
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 1, 'a', 'x', '' );
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 2, 'b', 'y', '' );
WPDO_Term_Comment_Misc_Bucket::on_comment_update( null, 5, 'c', 'z', '' );
$this->assertSame( 2, WPDO_Term_Comment_Misc_Bucket::count_rows( 'term' ) );
$this->assertSame( 1, WPDO_Term_Comment_Misc_Bucket::count_rows( 'comment' ) );
}
}
@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Term_Comment_Shadow_Verifier (v2.12.5 Phase 5).
*
* Verifies the sample-and-compare contract:
* - sample_compare counts matched / diffs / missing_flat / missing_meta
* - throws on invalid entity_type / sample_size / empty keys
* - cron_tick gated by mode (no-op when neither term nor comment is shadow_read)
* - run_all aggregates per-group results
*/
class TermCommentShadowVerifierTest extends TestCase {
private const TERMS = 'wp_itest_terms';
private const TERMMETA = 'wp_itest_termmeta';
private const COMMENTS = 'wp_itest_comments';
private const COMMENTMETA = 'wp_itest_commentmeta';
private const TERM_FLAT = 'wp_itest_wpdo_term_hp_taxonomy';
private const COMMENT_FLAT = 'wp_itest_wpdo_comment_hp_review';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Term_Comment_Shadow_Verifier' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-term-comment-shadow-verifier.php';
}
$wpdb->terms = self::TERMS;
$wpdb->termmeta = self::TERMMETA;
$wpdb->comments = self::COMMENTS;
$wpdb->commentmeta = self::COMMENTMETA;
// Source tables (terms / comments) need term_id / comment_ID columns.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TERMS . '` (
term_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
name varchar(200) NOT NULL DEFAULT "",
PRIMARY KEY (term_id)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::COMMENTS . '` (
comment_ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
comment_post_ID bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (comment_ID)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::TERMMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
term_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY term_id (term_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4' );
$wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::COMMENTMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
comment_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY comment_id (comment_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_FLAT . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TERM_FLAT . '` (
term_id bigint(20) unsigned NOT NULL,
hp_sort_order int(11) DEFAULT NULL,
hp_default tinyint(1) DEFAULT NULL,
hp_icon varchar(64) DEFAULT NULL,
PRIMARY KEY (term_id)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENT_FLAT . '`' );
$wpdb->query(
'CREATE TABLE `' . self::COMMENT_FLAT . '` (
comment_id bigint(20) unsigned NOT NULL,
hp_rating tinyint(1) DEFAULT NULL,
PRIMARY KEY (comment_id)
) DEFAULT CHARACTER SET utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
foreach ( array( self::TERMS, self::COMMENTS, self::TERM_FLAT, self::COMMENT_FLAT ) as $tbl ) {
$wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' );
}
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TERMS . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTS . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::TERMMETA . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTMETA . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::TERM_FLAT . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::COMMENT_FLAT . '`' );
}
// ── sample_compare contract ─────────────────────────────────────────────
public function test_sample_compare_invalid_entity_type_throws(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'bogus',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' )
);
}
public function test_sample_compare_zero_sample_size_throws(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' ),
0
);
}
public function test_sample_compare_empty_keys_throws(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array()
);
}
public function test_sample_compare_returns_zeros_for_empty_db(): void {
$result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' )
);
$this->assertSame( 0, $result['sampled'] );
$this->assertSame( 'term', $result['entity_type'] );
$this->assertSame( 'hp_taxonomy', $result['group'] );
}
public function test_sample_compare_counts_matches_when_in_sync(): void {
global $wpdb;
// 3 terms, all in sync between wp_termmeta and flat table.
for ( $i = 1; $i <= 3; $i++ ) {
$wpdb->insert( self::TERMS, array( 'term_id' => $i, 'name' => 'term_' . $i ) );
$wpdb->insert( self::TERMMETA, array( 'term_id' => $i, 'meta_key' => 'hp_sort_order', 'meta_value' => $i * 10 ) );
$wpdb->insert( self::TERM_FLAT, array( 'term_id' => $i, 'hp_sort_order' => $i * 10 ) );
}
$result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' ),
10
);
$this->assertSame( 3, $result['sampled'] );
$this->assertSame( 3, $result['matched'] );
$this->assertSame( 0, $result['diffs'] );
$this->assertSame( 0, $result['missing_flat'] );
$this->assertSame( 0, $result['missing_meta'] );
}
public function test_sample_compare_detects_missing_flat(): void {
global $wpdb;
// 2 terms with wp_termmeta but no flat row.
$wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) );
$wpdb->insert( self::TERMS, array( 'term_id' => 2, 'name' => 'b' ) );
$wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => 5 ) );
$wpdb->insert( self::TERMMETA, array( 'term_id' => 2, 'meta_key' => 'hp_sort_order', 'meta_value' => 10 ) );
$result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' ),
10
);
$this->assertSame( 2, $result['sampled'] );
$this->assertSame( 0, $result['matched'] );
$this->assertSame( 2, $result['missing_flat'] );
}
public function test_sample_compare_detects_missing_meta(): void {
global $wpdb;
// 2 terms with flat rows but no wp_termmeta.
$wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) );
$wpdb->insert( self::TERMS, array( 'term_id' => 2, 'name' => 'b' ) );
$wpdb->insert( self::TERM_FLAT, array( 'term_id' => 1, 'hp_sort_order' => 5 ) );
$wpdb->insert( self::TERM_FLAT, array( 'term_id' => 2, 'hp_sort_order' => 10 ) );
$result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' ),
10
);
$this->assertSame( 2, $result['sampled'] );
$this->assertSame( 2, $result['missing_meta'] );
}
public function test_sample_compare_detects_value_diff(): void {
global $wpdb;
$wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) );
$wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ) );
$wpdb->insert( self::TERM_FLAT, array( 'term_id' => 1, 'hp_sort_order' => 99 ) );
$result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' ),
10
);
$this->assertSame( 1, $result['diffs'] );
$this->assertSame( 0, $result['matched'] );
}
public function test_sample_compare_loose_equal_matches_numeric(): void {
global $wpdb;
$wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) );
// wp_termmeta stores '5' as string, flat stores 5 as int — should match
$wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ) );
$wpdb->insert( self::TERM_FLAT, array( 'term_id' => 1, 'hp_sort_order' => 5 ) );
$result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' ),
10
);
$this->assertSame( 1, $result['matched'], 'String "5" must loose-equal int 5' );
$this->assertSame( 0, $result['diffs'] );
}
public function test_sample_compare_handles_comment_entity(): void {
global $wpdb;
$wpdb->insert( self::COMMENTS, array( 'comment_ID' => 1, 'comment_post_ID' => 100 ) );
$wpdb->insert( self::COMMENTMETA, array( 'comment_id' => 1, 'meta_key' => 'hp_rating', 'meta_value' => 5 ) );
$wpdb->insert( self::COMMENT_FLAT, array( 'comment_id' => 1, 'hp_rating' => 5 ) );
$result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'comment',
'hp_review',
self::COMMENT_FLAT,
array( 'hp_rating' ),
10
);
$this->assertSame( 1, $result['sampled'] );
$this->assertSame( 1, $result['matched'] );
$this->assertSame( 'comment', $result['entity_type'] );
}
// ── cron_tick mode-gating ───────────────────────────────────────────────
public function test_cron_tick_no_op_when_neither_in_shadow_read(): void {
// Set both modes to dual_write so cron should no-op.
if ( class_exists( 'WPDO_Mode_Manager' ) ) {
WPDO_Mode_Manager::set( 'term', 'dual_write' );
WPDO_Mode_Manager::set( 'comment', 'dual_write' );
}
// cron_tick should return without error and not touch the tables.
WPDO_Term_Comment_Shadow_Verifier::cron_tick();
$this->assertTrue( true ); // No exception = no-op succeeded
}
}
+315
View File
@@ -0,0 +1,315 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Term_Stress_Tester (v2.13.0).
*
* Verifies the term stress tester contract:
* - State machine: start / get_state / get_progress / cancel
* - count_test_terms() matches slug prefix
* - cleanup() removes test terms + cascade
* - Input validation throws / errors correctly
* - Run benchmark structure
*
* Note: Realistic mode tests (wp_insert_term path) are exercised live in dev10
* smoke tests since the bootstrap wp_insert_term stub is intentionally minimal.
*/
class TermStressTesterTest extends TestCase {
private const TERMS = 'wp_itest_terms';
private const TERM_TAXONOMY = 'wp_itest_term_taxonomy';
private const TERMMETA = 'wp_itest_termmeta';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Term_Stress_Tester' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-term-stress-tester.php';
}
$wpdb->terms = self::TERMS;
$wpdb->termmeta = self::TERMMETA;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TERMS . '` (
term_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
name varchar(200) NOT NULL DEFAULT "",
slug varchar(200) NOT NULL DEFAULT "",
term_group bigint(10) NOT NULL DEFAULT 0,
PRIMARY KEY (term_id),
KEY slug (slug(191))
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_TAXONOMY . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TERM_TAXONOMY . '` (
term_taxonomy_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
term_id bigint(20) unsigned NOT NULL DEFAULT 0,
taxonomy varchar(32) NOT NULL DEFAULT "",
description longtext,
parent bigint(20) unsigned NOT NULL DEFAULT 0,
count bigint(20) NOT NULL DEFAULT 0,
PRIMARY KEY (term_taxonomy_id),
KEY taxonomy (taxonomy)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::TERMMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
term_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY term_id (term_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4' );
}
public static function tearDownAfterClass(): void {
global $wpdb;
foreach ( array( self::TERMS, self::TERM_TAXONOMY, self::TERMMETA ) as $tbl ) {
$wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' );
}
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TERMS . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::TERM_TAXONOMY . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::TERMMETA . '`' );
// Reset state per test so each starts idle.
unset( $GLOBALS['_wp_options'][ WPDO_Term_Stress_Tester::OPT_STATE ] );
unset( $GLOBALS['_wp_transients'][ WPDO_Term_Stress_Tester::CANCEL_FLAG ] );
unset( $GLOBALS['_wp_transients']['wpdo_term_stress_pump_lock'] );
}
// ── create() (fast-path direct SQL) ──────────────────────────────────────
public function test_create_inserts_terms_into_taxonomy(): void {
$result = WPDO_Term_Stress_Tester::create( 'category', 5 );
$this->assertSame( 5, $result['created'] );
$this->assertSame( 'category', $result['taxonomy'] );
$this->assertNotNull( $result['first_id'] );
$this->assertNotNull( $result['last_id'] );
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMS . '`' );
$this->assertSame( 5, $count );
$tax_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::TERM_TAXONOMY . "` WHERE taxonomy = 'category'" );
$this->assertSame( 5, $tax_count );
}
public function test_create_uses_stress_slug_prefix(): void {
WPDO_Term_Stress_Tester::create( 'category', 3 );
global $wpdb;
$prefix_count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `" . self::TERMS . "` WHERE slug LIKE %s",
WPDO_Term_Stress_Tester::TEST_TERM_PREFIX . '%'
)
);
$this->assertSame( 3, $prefix_count );
}
public function test_create_seeds_termmeta_keys(): void {
WPDO_Term_Stress_Tester::create( 'category', 3 );
global $wpdb;
$total_meta = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' );
// 3 terms × 3 keys (hp_sort_order/hp_default/hp_icon) = 9
$this->assertSame( 9, $total_meta );
}
public function test_create_rejects_empty_taxonomy(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Term_Stress_Tester::create( '', 3 );
}
public function test_create_rejects_zero_count(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Term_Stress_Tester::create( 'category', 0 );
}
public function test_create_rejects_excessive_count(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Term_Stress_Tester::create( 'category', 100001 );
}
// ── count_test_terms() ────────────────────────────────────────────────────
public function test_count_test_terms_returns_zero_for_empty(): void {
$this->assertSame( 0, WPDO_Term_Stress_Tester::count_test_terms() );
}
public function test_count_test_terms_counts_only_stress_prefix(): void {
WPDO_Term_Stress_Tester::create( 'category', 4 );
global $wpdb;
$wpdb->insert( self::TERMS, array( 'name' => 'Real', 'slug' => 'real-term', 'term_group' => 0 ) );
$this->assertSame( 4, WPDO_Term_Stress_Tester::count_test_terms() );
}
// ── cleanup() ─────────────────────────────────────────────────────────────
public function test_cleanup_removes_test_terms_and_cascade(): void {
WPDO_Term_Stress_Tester::create( 'category', 5 );
$this->assertSame( 5, WPDO_Term_Stress_Tester::count_test_terms() );
$result = WPDO_Term_Stress_Tester::cleanup();
$this->assertSame( 5, $result['deleted_terms'] );
global $wpdb;
$this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMS . '`' ) );
$this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' ) );
$this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERM_TAXONOMY . '`' ) );
}
public function test_cleanup_preserves_non_stress_terms(): void {
global $wpdb;
$wpdb->insert( self::TERMS, array( 'name' => 'Real', 'slug' => 'real-term', 'term_group' => 0 ) );
WPDO_Term_Stress_Tester::create( 'category', 3 );
$result = WPDO_Term_Stress_Tester::cleanup();
$this->assertSame( 3, $result['deleted_terms'] );
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMS . '`' );
$this->assertSame( 1, $remaining );
}
public function test_cleanup_idempotent_on_empty(): void {
$first = WPDO_Term_Stress_Tester::cleanup();
$second = WPDO_Term_Stress_Tester::cleanup();
$this->assertSame( 0, $first['deleted_terms'] );
$this->assertSame( 0, $second['deleted_terms'] );
}
// ── State machine ────────────────────────────────────────────────────────
public function test_get_state_returns_empty_when_idle(): void {
$this->assertSame( array(), WPDO_Term_Stress_Tester::get_state() );
}
public function test_get_progress_returns_idle_when_no_state(): void {
$progress = WPDO_Term_Stress_Tester::get_progress( false );
$this->assertSame( 'idle', $progress['status'] );
}
public function test_start_persists_state_with_running_status(): void {
// Stub taxonomy_exists() — fall back to true via global flag.
$GLOBALS['_taxonomy_exists_override'] = true;
$result = WPDO_Term_Stress_Tester::start( 'category', 10, 'fast', 5 );
$this->assertTrue( $result['ok'], 'start should succeed' );
$state = $result['state'];
$this->assertSame( 'running', $state['status'] );
$this->assertSame( 'category', $state['taxonomy'] );
$this->assertSame( 'fast', $state['mode'] );
$this->assertSame( 10, $state['target'] );
$this->assertSame( 5, $state['batch_size'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
public function test_start_rejects_unknown_taxonomy(): void {
$GLOBALS['_taxonomy_exists_override'] = false;
$result = WPDO_Term_Stress_Tester::start( 'never_exists', 10 );
$this->assertFalse( $result['ok'] );
$this->assertStringContainsString( 'unknown_taxonomy', $result['error'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
public function test_start_rejects_invalid_mode(): void {
$GLOBALS['_taxonomy_exists_override'] = true;
$result = WPDO_Term_Stress_Tester::start( 'category', 10, 'turbo' );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'invalid mode', $result['error'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
public function test_start_rejects_concurrent_run(): void {
$GLOBALS['_taxonomy_exists_override'] = true;
WPDO_Term_Stress_Tester::start( 'category', 10 );
$result = WPDO_Term_Stress_Tester::start( 'category', 5 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'already_running', $result['error'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
public function test_run_batch_advances_processed_count(): void {
$GLOBALS['_taxonomy_exists_override'] = true;
WPDO_Term_Stress_Tester::start( 'category', 6, 'fast', 3 );
WPDO_Term_Stress_Tester::run_batch();
$progress = WPDO_Term_Stress_Tester::get_progress( false );
$this->assertSame( 3, $progress['processed'] );
$this->assertSame( 1, $progress['batches_done'] );
$this->assertSame( 'running', $progress['status'] );
WPDO_Term_Stress_Tester::run_batch();
$progress = WPDO_Term_Stress_Tester::get_progress( false );
$this->assertSame( 6, $progress['processed'] );
$this->assertSame( 'completed', $progress['status'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
public function test_cancel_marks_state_as_cancelled(): void {
$GLOBALS['_taxonomy_exists_override'] = true;
WPDO_Term_Stress_Tester::start( 'category', 100, 'fast', 50 );
$result = WPDO_Term_Stress_Tester::cancel();
$this->assertTrue( $result['ok'] );
$this->assertSame( 'cancelled', $result['state']['status'] );
// In-flight batch run after cancel must NOT bump status back to running.
WPDO_Term_Stress_Tester::run_batch();
$state = WPDO_Term_Stress_Tester::get_state();
$this->assertSame( 'cancelled', $state['status'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
public function test_cancel_returns_no_active_job_when_idle(): void {
$result = WPDO_Term_Stress_Tester::cancel();
$this->assertTrue( $result['ok'] );
$this->assertSame( 'no_active_job', $result['message'] ?? '' );
}
public function test_get_progress_includes_pct_and_eta_keys(): void {
$GLOBALS['_taxonomy_exists_override'] = true;
WPDO_Term_Stress_Tester::start( 'category', 10, 'fast', 5 );
WPDO_Term_Stress_Tester::run_batch();
$progress = WPDO_Term_Stress_Tester::get_progress( false );
$this->assertArrayHasKey( 'pct', $progress );
$this->assertArrayHasKey( 'rate_per_sec', $progress );
$this->assertArrayHasKey( 'elapsed_sec', $progress );
$this->assertArrayHasKey( 'eta_sec', $progress );
$this->assertArrayHasKey( 'test_term_count', $progress );
$this->assertSame( 50.0, $progress['pct'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
public function test_run_benchmark_returns_structured_payload(): void {
$GLOBALS['_taxonomy_exists_override'] = true;
WPDO_Term_Stress_Tester::start( 'category', 4, 'fast', 4 );
WPDO_Term_Stress_Tester::run_batch();
$state = WPDO_Term_Stress_Tester::get_state();
$this->assertSame( 'completed', $state['status'] );
$this->assertIsArray( $state['benchmark'] );
$this->assertArrayHasKey( 'write', $state['benchmark'] );
$this->assertArrayHasKey( 'db_sizes', $state['benchmark'] );
$this->assertSame( 'category', $state['benchmark']['taxonomy'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
}
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Termmeta_Cleaner — wp_termmeta garbage cleanup (v2.12.0).
*
* Verifies count_garbage() and delete_garbage() against a real MariaDB test table:
* - target=wxr_import → meta_key LIKE '_wxr_import_%'
* - target=demo_data → meta_key LIKE '_2meet_demo_%'
* - target=transients → meta_key LIKE '_transient_%' OR LIKE '_transient_timeout_%'
* - target=all → union of all three
*/
class TermmetaCleanerIntegrationTest extends TestCase {
private const TERMMETA = 'wp_itest_termmeta';
public static function setUpBeforeClass(): void {
global $wpdb;
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-termmeta-cleaner.php';
// Override $wpdb->termmeta to point at our test table.
$wpdb->termmeta = self::TERMMETA;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMMETA . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TERMMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
term_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY term_id (term_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMMETA . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TERMMETA . '`' );
}
private function seed( array $rows ): void {
global $wpdb;
foreach ( $rows as $row ) {
$wpdb->insert( self::TERMMETA, $row );
}
}
// ── count_garbage ────────────────────────────────────────────────────────
public function test_count_garbage_returns_zero_for_empty_table(): void {
$counts = WPDO_Termmeta_Cleaner::count_garbage( 'all' );
$this->assertSame( 0, $counts['wxr_import'] );
$this->assertSame( 0, $counts['demo_data'] );
$this->assertSame( 0, $counts['transients'] );
$this->assertSame( 0, $counts['total'] );
}
public function test_count_garbage_counts_wxr_import(): void {
$this->seed( array(
array( 'term_id' => 1, 'meta_key' => '_wxr_import_user_xyz', 'meta_value' => 'a' ),
array( 'term_id' => 2, 'meta_key' => '_wxr_import_post', 'meta_value' => 'b' ),
array( 'term_id' => 3, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ),
) );
$counts = WPDO_Termmeta_Cleaner::count_garbage( 'wxr_import' );
$this->assertSame( 2, $counts['wxr_import'] );
$this->assertSame( 0, $counts['demo_data'] );
$this->assertSame( 0, $counts['transients'] );
$this->assertSame( 2, $counts['total'] );
}
public function test_count_garbage_counts_demo_data(): void {
$this->seed( array(
array( 'term_id' => 1, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
array( 'term_id' => 2, 'meta_key' => '_2meet_demo_adv', 'meta_value' => '1' ),
array( 'term_id' => 3, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ),
) );
$counts = WPDO_Termmeta_Cleaner::count_garbage( 'demo_data' );
$this->assertSame( 0, $counts['wxr_import'] );
$this->assertSame( 2, $counts['demo_data'] );
$this->assertSame( 0, $counts['transients'] );
$this->assertSame( 2, $counts['total'] );
}
public function test_count_garbage_counts_transients(): void {
$this->seed( array(
array( 'term_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ),
array( 'term_id' => 1, 'meta_key' => '_transient_timeout_foo', 'meta_value' => '9999' ),
array( 'term_id' => 2, 'meta_key' => 'hp_default', 'meta_value' => '1' ),
) );
$counts = WPDO_Termmeta_Cleaner::count_garbage( 'transients' );
$this->assertSame( 2, $counts['transients'] );
$this->assertSame( 2, $counts['total'] );
}
public function test_count_garbage_all_unions_three_buckets(): void {
$this->seed( array(
array( 'term_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
array( 'term_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
array( 'term_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
array( 'term_id' => 4, 'meta_key' => 'hp_icon', 'meta_value' => 'star' ),
array( 'term_id' => 5, 'meta_key' => 'hp_default', 'meta_value' => '1' ),
) );
$counts = WPDO_Termmeta_Cleaner::count_garbage( 'all' );
$this->assertSame( 1, $counts['wxr_import'] );
$this->assertSame( 1, $counts['demo_data'] );
$this->assertSame( 1, $counts['transients'] );
$this->assertSame( 3, $counts['total'] );
}
// ── delete_garbage ────────────────────────────────────────────────────────
public function test_delete_garbage_removes_targeted_rows_only(): void {
$this->seed( array(
array( 'term_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
array( 'term_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
array( 'term_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
array( 'term_id' => 4, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ),
) );
$deleted = WPDO_Termmeta_Cleaner::delete_garbage( 'all' );
$this->assertSame( 1, $deleted['wxr_import'] );
$this->assertSame( 1, $deleted['demo_data'] );
$this->assertSame( 1, $deleted['transients'] );
$this->assertSame( 3, $deleted['total'] );
global $wpdb;
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' );
$this->assertSame( 1, $remaining, 'hp_sort_order must survive' );
}
public function test_delete_garbage_target_specific_only_removes_one_bucket(): void {
$this->seed( array(
array( 'term_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
array( 'term_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
array( 'term_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
) );
$deleted = WPDO_Termmeta_Cleaner::delete_garbage( 'wxr_import' );
$this->assertSame( 1, $deleted['wxr_import'] );
$this->assertSame( 0, $deleted['demo_data'] );
$this->assertSame( 0, $deleted['transients'] );
$this->assertSame( 1, $deleted['total'] );
global $wpdb;
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' );
$this->assertSame( 2, $remaining, '_2meet_demo + _transient must survive when target=wxr_import' );
}
public function test_delete_garbage_idempotent_on_clean_table(): void {
$this->seed( array(
array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ),
) );
$first = WPDO_Termmeta_Cleaner::delete_garbage( 'all' );
$second = WPDO_Termmeta_Cleaner::delete_garbage( 'all' );
$this->assertSame( 0, $first['total'] );
$this->assertSame( 0, $second['total'] );
global $wpdb;
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' );
$this->assertSame( 1, $remaining );
}
public function test_invalid_target_throws(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Termmeta_Cleaner::count_garbage( 'bogus' );
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_V2_Upgrader — atomic v1.3.x → v2.0.0 upgrade (PR-7).
*
* Covers the four upgrade scenarios from Part F.2:
* S1: clean install
* S2: v1.3.x → v2.0.0 (no UAE) ← dev10 production case
* S3: v1.3.x + UAE coexistence
* S4: HPCT residue (covered by existing wpdo_hpct_imported flag)
*
* @covers WPDO_V2_Upgrader
*/
class UpgradeV2Test extends TestCase {
public static function setUpBeforeClass(): void {
global $wpdb;
// Reset state for repeatability.
foreach ( array( 'audit', 'shadow_diffs', 'site_metrics', 'uni_options' ) as $t ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}wpdo_{$t}`" );
}
// Drop any leftover wp_uae_* probe tables from prior test runs.
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}uae_probe`" );
}
protected function setUp(): void {
// Clean options between tests for clean preconditions.
$GLOBALS['_wp_options'] = array();
}
// ── Pre-flight ──────────────────────────────────────────────────────────
public function test_pre_flight_passes_in_clean_environment(): void {
$checks = WPDO_V2_Upgrader::pre_flight_check();
$this->assertIsArray( $checks );
// PHP / WP / MySQL versions in this CI/dev environment must satisfy minimums.
$this->assertTrue( $checks['php_version'] );
$this->assertTrue( $checks['mysql_version'] );
}
public function test_pre_flight_detects_old_php(): void {
// Simulating an old PHP is impossible from PHP itself, so we only
// verify the keys are present + booleans.
$checks = WPDO_V2_Upgrader::pre_flight_check();
foreach ( array( 'php_version', 'wp_version', 'mysql_version', 'free_disk_mb', 'features_writable', 'no_active_migration' ) as $key ) {
$this->assertArrayHasKey( $key, $checks );
$this->assertIsBool( $checks[ $key ] );
}
}
// ── S1: clean install ─────────────────────────────────────────────────
public function test_s1_clean_install_creates_v2_schema(): void {
WPDO_V2_Upgrader::upgrade_to_v2();
$status = WPDO_Installer::v2_tables_status();
foreach ( $status as $exists ) {
$this->assertTrue( $exists );
}
}
// ── S2: v1.3.x → v2.0.0 (no UAE) ───────────────────────────────────────
public function test_s2_upgrade_marks_db_version(): void {
// Simulate v1.3.x state.
update_option( 'wpdo_db_version', '1.0.0' );
$ok = WPDO_V2_Upgrader::upgrade_to_v2();
$this->assertTrue( $ok );
$this->assertSame( '2.0.0', get_option( 'wpdo_db_version' ) );
$this->assertSame( 'complete', get_option( 'wpdo_v2_upgrade_status' ) );
}
public function test_s2_upgrade_seeds_entity_modules_in_features(): void {
update_option( 'wpdo_features', array( 'hot_hp_listing' => 'cutover' ) );
WPDO_V2_Upgrader::upgrade_to_v2();
$flags = get_option( 'wpdo_features' );
$this->assertSame( 'cutover', $flags['hot_hp_listing'], 'Pre-existing module state must be preserved' );
foreach ( array( 'entity_user', 'entity_term', 'entity_comment', 'entity_options' ) as $module ) {
$this->assertArrayHasKey( $module, $flags );
$this->assertSame( 'idle', $flags[ $module ] );
}
}
public function test_s2_upgrade_idempotent(): void {
WPDO_V2_Upgrader::upgrade_to_v2();
$ok = WPDO_V2_Upgrader::upgrade_to_v2(); // Second run must not fail.
$this->assertTrue( $ok );
}
// ── S3: UAE coexistence ───────────────────────────────────────────────
public function test_s3_detects_uae_data_when_table_present(): void {
global $wpdb;
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->prefix}uae_probe` ( id BIGINT PRIMARY KEY ) ENGINE=InnoDB" );
$this->assertTrue( WPDO_V2_Upgrader::detect_uae_data() );
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}uae_probe`" );
}
public function test_s3_no_uae_data_in_clean_environment(): void {
// dev10 case: no wp_uae_* tables.
$this->assertFalse( WPDO_V2_Upgrader::detect_uae_data() );
}
// ── Rollback ───────────────────────────────────────────────────────────
public function test_rollback_restores_features_backup(): void {
$original = array( 'hot_hp_listing' => 'cutover' );
update_option( 'wpdo_features', $original );
WPDO_V2_Upgrader::upgrade_to_v2();
// Simulate user wants to roll back.
WPDO_V2_Upgrader::rollback_v2( true );
$this->assertSame( $original, get_option( 'wpdo_features' ) );
$this->assertSame( '1.0.0', get_option( 'wpdo_db_version' ) );
$this->assertSame( 'rolled_back', get_option( 'wpdo_v2_upgrade_status' ) );
}
public function test_rollback_with_keep_data_preserves_v2_tables(): void {
WPDO_V2_Upgrader::upgrade_to_v2();
WPDO_V2_Upgrader::rollback_v2( true );
$status = WPDO_Installer::v2_tables_status();
foreach ( $status as $table => $exists ) {
$this->assertTrue( $exists, "{$table} must remain after rollback with --keep-data" );
}
}
}
@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
/**
* Integration tests — Warm Zone (Zone B) + Archive Zone (Zone D) lifecycle.
*
* Covers:
* - Warm zone: set/get/TTL/purge_expired/flush_views
* - Archive zone: archive_batch/get/stats/restore/gzip roundtrip
* - WPDO_Listing_Stats: increment + flush + REST view count
*
* Uses dedicated test tables (wp_itest_ prefix) to avoid collisions.
*/
use PHPUnit\Framework\TestCase;
class WarmArchiveIntegrationTest extends TestCase {
private static string $warm_table;
private static string $archive_table;
private static string $errors_table;
private static string $postmeta_table;
public static function setUpBeforeClass(): void {
global $wpdb;
self::$warm_table = $wpdb->prefix . 'wpdo_warm';
self::$archive_table = $wpdb->prefix . 'wpdo_archive';
self::$errors_table = $wpdb->prefix . 'wpdo_errors';
self::$postmeta_table = $wpdb->postmeta;
// DROP + CREATE ensures clean schema even after interrupted prior runs.
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
$wpdb->query(
"CREATE TABLE `" . self::$warm_table . "` (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
meta_key VARCHAR(255) NOT NULL,
meta_value LONGTEXT,
expires_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY post_meta (post_id, meta_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Create archive table (matches WPDO_Installer::install_system_tables DDL).
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$archive_table . "`" );
$wpdb->query(
"CREATE TABLE `" . self::$archive_table . "` (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
post_type VARCHAR(20) NOT NULL DEFAULT '',
meta_key VARCHAR(255) NOT NULL DEFAULT '',
meta_value LONGTEXT,
compressed TINYINT(1) NOT NULL DEFAULT 0,
archived_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
original_meta_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_post_id (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Create errors table (needed by WPDO_Logger).
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$errors_table . "`" );
$wpdb->query(
"CREATE TABLE `" . self::$errors_table . "` (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
severity VARCHAR(20) NOT NULL DEFAULT 'error',
module VARCHAR(100) NOT NULL DEFAULT '',
zone VARCHAR(50) DEFAULT NULL,
hook VARCHAR(100) NOT NULL DEFAULT '',
message TEXT NOT NULL,
context LONGTEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Create postmeta table (needed by flush_views_to_postmeta batch query).
// Only create if it does not already exist — shared with other test classes.
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `" . self::$postmeta_table . "` (
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),
KEY post_id (post_id),
KEY meta_key (meta_key(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$archive_table . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$errors_table . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$postmeta_table . "`" );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( "TRUNCATE TABLE `" . self::$warm_table . "`" );
$wpdb->query( "TRUNCATE TABLE `" . self::$archive_table . "`" );
$GLOBALS['_wp_cache'] = [];
$GLOBALS['_wp_postmeta'] = [];
$GLOBALS['_wp_options'] = [];
}
// ── Zone B (Warm) ─────────────────────────────────────────────────────────
public function test_warm_set_and_get(): void {
WPDO_Zone_Warm::set( 100, 'wp_key', 'hello', null );
$val = WPDO_Zone_Warm::get( 100, 'wp_key' );
$this->assertSame( 'hello', $val );
}
public function test_warm_expired_returns_null(): void {
global $wpdb;
// Insert already-expired entry.
$wpdb->query(
"INSERT INTO `" . self::$warm_table . "` (post_id, meta_key, meta_value, expires_at)
VALUES (101, 'stale_key', 'old', '2000-01-01 00:00:00')"
);
$val = WPDO_Zone_Warm::get( 101, 'stale_key' );
$this->assertNull( $val );
}
public function test_warm_purge_expired(): void {
global $wpdb;
$wpdb->query(
"INSERT INTO `" . self::$warm_table . "` (post_id, meta_key, meta_value, expires_at)
VALUES (102, 'k1', 'v1', '2000-01-01 00:00:00'),
(103, 'k2', 'v2', DATE_ADD(NOW(), INTERVAL 1 HOUR))"
);
$deleted = WPDO_Zone_Warm::purge_expired();
$this->assertGreaterThanOrEqual( 1, $deleted );
// k2 (future TTL) should still exist.
$this->assertNotNull( WPDO_Zone_Warm::get( 103, 'k2' ) );
}
public function test_warm_delete_all_for_post(): void {
WPDO_Zone_Warm::set( 200, 'a', 'va', null );
WPDO_Zone_Warm::set( 200, 'b', 'vb', null );
WPDO_Zone_Warm::set( 201, 'a', 'other', null );
WPDO_Zone_Warm::delete_all( 200 );
$this->assertNull( WPDO_Zone_Warm::get( 200, 'a' ) );
$this->assertNull( WPDO_Zone_Warm::get( 200, 'b' ) );
$this->assertSame( 'other', WPDO_Zone_Warm::get( 201, 'a' ) );
}
// ── Zone D (Archive) ──────────────────────────────────────────────────────
/** Helper: build a row for archive_batch with required post_type. */
private function archive_rows( int $post_id, array $metas ): array {
return array_map( fn( $m ) => array_merge(
[ 'post_id' => $post_id, 'post_type' => 'hp_listing', 'meta_id' => 0 ],
$m
), $metas );
}
public function test_archive_batch_stores_compressed(): void {
global $wpdb;
$rows = $this->archive_rows( 400, [
[ 'meta_key' => 'hp_price', 'meta_value' => '999' ],
[ 'meta_key' => 'hp_featured', 'meta_value' => '1' ],
] );
WPDO_Zone_Archive::archive_batch( $rows, true );
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$archive_table . "` WHERE post_id = 400" );
$this->assertSame( 2, $count );
$comp = (int) $wpdb->get_var( "SELECT SUM(compressed) FROM `" . self::$archive_table . "` WHERE post_id = 400" );
$this->assertSame( 2, $comp );
}
public function test_archive_get_returns_values(): void {
$rows = $this->archive_rows( 401, [
[ 'meta_key' => 'hp_price', 'meta_value' => '500' ],
[ 'meta_key' => 'hp_verified', 'meta_value' => '1' ],
] );
WPDO_Zone_Archive::archive_batch( $rows, false );
$result = WPDO_Zone_Archive::get( 401 );
$this->assertCount( 2, $result );
$by_key = array_column( $result, 'meta_value', 'meta_key' );
$this->assertSame( '500', $by_key['hp_price'] );
$this->assertSame( '1', $by_key['hp_verified'] );
}
public function test_archive_get_with_key_filter(): void {
$rows = $this->archive_rows( 402, [
[ 'meta_key' => 'hp_price', 'meta_value' => '250' ],
[ 'meta_key' => 'hp_featured', 'meta_value' => '0' ],
] );
WPDO_Zone_Archive::archive_batch( $rows, false );
$result = WPDO_Zone_Archive::get( 402, 'hp_price' );
$this->assertCount( 1, $result );
$this->assertSame( 'hp_price', $result[0]['meta_key'] );
}
public function test_archive_restore_writes_postmeta(): void {
global $wpdb;
$rows = $this->archive_rows( 403, [
[ 'meta_key' => 'hp_price', 'meta_value' => '777' ],
] );
WPDO_Zone_Archive::archive_batch( $rows, false );
$restored = WPDO_Zone_Archive::restore( 403 );
$this->assertSame( 1, $restored );
$pm = $GLOBALS['_wp_postmeta'][403]['hp_price'] ?? null;
$this->assertSame( '777', $pm );
$remaining = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$archive_table . "` WHERE post_id = 403" );
$this->assertSame( 0, $remaining );
}
public function test_archive_gzip_roundtrip(): void {
$rows = $this->archive_rows( 404, [
[ 'meta_key' => 'hp_description', 'meta_value' => str_repeat( 'Lorem ipsum ', 50 ) ],
] );
WPDO_Zone_Archive::archive_batch( $rows, true );
$result = WPDO_Zone_Archive::get( 404 );
$this->assertCount( 1, $result );
$this->assertStringContainsString( 'Lorem ipsum', $result[0]['meta_value'] );
}
public function test_archive_delete_removes_rows(): void {
global $wpdb;
$rows = $this->archive_rows( 405, [
[ 'meta_key' => 'hp_price', 'meta_value' => '1' ],
] );
WPDO_Zone_Archive::archive_batch( $rows, false );
WPDO_Zone_Archive::delete( 405 );
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$archive_table . "` WHERE post_id = 405" );
$this->assertSame( 0, $count );
}
public function test_archive_stats_counts_correctly(): void {
global $wpdb;
$wpdb->query( "TRUNCATE TABLE `" . self::$archive_table . "`" );
WPDO_Zone_Archive::archive_batch( $this->archive_rows( 500, [
[ 'meta_key' => 'k1', 'meta_value' => 'a' ],
[ 'meta_key' => 'k2', 'meta_value' => 'b' ],
] ), true );
WPDO_Zone_Archive::archive_batch( $this->archive_rows( 502, [
[ 'meta_key' => 'k3', 'meta_value' => 'c' ],
] ), false );
$stats = WPDO_Zone_Archive::stats();
$this->assertSame( 3, $stats['total_rows'] );
$this->assertSame( 2, $stats['compressed_rows'] );
}
}
@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Zone_Archive against real MariaDB.
*
* Covers archive/get (with gzip decompression), archive_batch (transaction),
* delete, stats(), and restore().
*/
class ZoneArchiveIntegrationTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_archive';
// ── Fixture lifecycle ─────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`post_type` varchar(20) NOT NULL DEFAULT \'\',
`meta_key` varchar(255) NOT NULL DEFAULT \'\',
`meta_value` longtext DEFAULT NULL,
`compressed` tinyint(1) NOT NULL DEFAULT 0,
`archived_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
`original_meta_id` bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `post_id` (`post_id`),
KEY `archived_at` (`archived_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
$GLOBALS['_wp_postmeta'] = [];
}
// ── archive / get ─────────────────────────────────────────────────────
public function test_archive_stores_plain_entry(): void {
WPDO_Zone_Archive::archive( 1, 'hp_listing', 'hp_price', '199.99' );
$rows = WPDO_Zone_Archive::get( 1 );
$this->assertCount( 1, $rows );
$this->assertSame( 'hp_price', $rows[0]['meta_key'] );
$this->assertSame( '199.99', $rows[0]['meta_value'] );
}
public function test_archive_with_compression_stores_and_decompresses(): void {
$original = 'large content that compresses well: ' . str_repeat( 'abcdef', 50 );
WPDO_Zone_Archive::archive( 2, 'hp_listing', 'hp_desc', $original, 0, true );
$rows = WPDO_Zone_Archive::get( 2, 'hp_desc' );
$this->assertCount( 1, $rows );
// get() decompresses automatically — value must match original.
$this->assertSame( $original, $rows[0]['meta_value'] );
}
public function test_get_with_meta_key_filter_returns_only_that_key(): void {
WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_price', '50.00' );
WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_featured', '1' );
WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_verified', '1' );
$rows = WPDO_Zone_Archive::get( 3, 'hp_price' );
$this->assertCount( 1, $rows );
$this->assertSame( 'hp_price', $rows[0]['meta_key'] );
}
public function test_get_without_filter_returns_all_keys(): void {
WPDO_Zone_Archive::archive( 4, 'hp_listing', 'hp_price', '75.00' );
WPDO_Zone_Archive::archive( 4, 'hp_listing', 'hp_featured', '0' );
$rows = WPDO_Zone_Archive::get( 4 );
$this->assertCount( 2, $rows );
}
public function test_get_returns_empty_for_missing_post(): void {
$rows = WPDO_Zone_Archive::get( 9999 );
$this->assertSame( [], $rows );
}
// ── archive_batch ────────────────────────────────────────────────────
public function test_archive_batch_stores_all_entries_in_transaction(): void {
$entries = [
[ 'post_id' => 5, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '100.00', 'meta_id' => 0 ],
[ 'post_id' => 5, 'post_type' => 'hp_listing', 'meta_key' => 'hp_featured', 'meta_value' => '1', 'meta_id' => 0 ],
[ 'post_id' => 6, 'post_type' => 'hp_vendor', 'meta_key' => 'hp_rate', 'meta_value' => '50.00', 'meta_id' => 0 ],
];
WPDO_Zone_Archive::archive_batch( $entries );
$this->assertCount( 2, WPDO_Zone_Archive::get( 5 ) );
$this->assertCount( 1, WPDO_Zone_Archive::get( 6 ) );
}
public function test_archive_batch_with_compression(): void {
$original = str_repeat( 'x', 200 );
WPDO_Zone_Archive::archive_batch(
[ [ 'post_id' => 7, 'post_type' => 'hp_listing', 'meta_key' => 'hp_desc', 'meta_value' => $original, 'meta_id' => 0 ] ],
true
);
$rows = WPDO_Zone_Archive::get( 7, 'hp_desc' );
$this->assertSame( $original, $rows[0]['meta_value'] );
}
// ── delete ───────────────────────────────────────────────────────────
public function test_delete_removes_all_entries_for_post(): void {
WPDO_Zone_Archive::archive( 8, 'hp_listing', 'hp_price', '30.00' );
WPDO_Zone_Archive::archive( 8, 'hp_listing', 'hp_featured', '1' );
$this->assertCount( 2, WPDO_Zone_Archive::get( 8 ) );
WPDO_Zone_Archive::delete( 8 );
$this->assertSame( [], WPDO_Zone_Archive::get( 8 ) );
}
public function test_delete_does_not_affect_other_posts(): void {
WPDO_Zone_Archive::archive( 9, 'hp_listing', 'hp_price', '10.00' );
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '20.00' );
WPDO_Zone_Archive::delete( 9 );
$this->assertSame( [], WPDO_Zone_Archive::get( 9 ) );
$this->assertCount( 1, WPDO_Zone_Archive::get( 10 ) );
}
// ── stats ─────────────────────────────────────────────────────────────
public function test_stats_counts_total_and_compressed_rows(): void {
WPDO_Zone_Archive::archive( 11, 'hp_listing', 'hp_price', '1.00', 0, false );
WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '2.00', 0, true );
WPDO_Zone_Archive::archive( 13, 'hp_listing', 'hp_price', '3.00', 0, true );
$stats = WPDO_Zone_Archive::stats();
$this->assertSame( 3, $stats['total_rows'] );
$this->assertSame( 2, $stats['compressed_rows'] );
}
public function test_stats_groups_by_post_type(): void {
WPDO_Zone_Archive::archive( 14, 'hp_listing', 'hp_price', '1.00' );
WPDO_Zone_Archive::archive( 15, 'hp_listing', 'hp_price', '2.00' );
WPDO_Zone_Archive::archive( 16, 'hp_vendor', 'hp_rate', '3.00' );
$stats = WPDO_Zone_Archive::stats();
$type_map = array_column( $stats['post_types'], 'cnt', 'post_type' );
$this->assertSame( '2', $type_map['hp_listing'] );
$this->assertSame( '1', $type_map['hp_vendor'] );
}
public function test_stats_returns_zeros_on_empty_table(): void {
$stats = WPDO_Zone_Archive::stats();
$this->assertSame( 0, $stats['total_rows'] );
$this->assertSame( 0, $stats['compressed_rows'] );
$this->assertSame( [], $stats['post_types'] );
}
// ── restore ───────────────────────────────────────────────────────────
public function test_restore_writes_to_postmeta_and_removes_from_archive(): void {
WPDO_Zone_Archive::archive( 17, 'hp_listing', 'hp_price', '99.00' );
WPDO_Zone_Archive::archive( 17, 'hp_listing', 'hp_featured', '1' );
$count = WPDO_Zone_Archive::restore( 17 );
// Two entries restored.
$this->assertSame( 2, $count );
// Postmeta updated via stub.
$this->assertSame( '99.00', $GLOBALS['_wp_postmeta'][17]['hp_price'] );
$this->assertSame( '1', $GLOBALS['_wp_postmeta'][17]['hp_featured'] );
// Archive cleared.
$this->assertSame( [], WPDO_Zone_Archive::get( 17 ) );
}
public function test_restore_with_meta_key_filter_only_restores_that_key(): void {
WPDO_Zone_Archive::archive( 18, 'hp_listing', 'hp_price', '55.00' );
WPDO_Zone_Archive::archive( 18, 'hp_listing', 'hp_featured', '0' );
$count = WPDO_Zone_Archive::restore( 18, 'hp_price' );
$this->assertSame( 1, $count );
$this->assertSame( '55.00', $GLOBALS['_wp_postmeta'][18]['hp_price'] );
// hp_featured should still be in archive.
$remaining = WPDO_Zone_Archive::get( 18 );
$this->assertCount( 1, $remaining );
$this->assertSame( 'hp_featured', $remaining[0]['meta_key'] );
}
}
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Zone_Cold against real MariaDB.
*
* Creates a dedicated cold table (wp_itest_wpdo_cold_itest) to exercise all
* Zone C operations: set / get, set_many / get_blob, remove, delete, and
* Object Cache invalidation.
*
* post_type = 'itest' maps to table prefix wp_itest_wpdo_cold_itest.
*/
class ZoneColdIntegrationTest extends TestCase {
private const POST_TYPE = 'itest';
/** Derived at runtime: $wpdb->prefix . 'wpdo_cold_itest' */
private static string $table;
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
self::$table = $wpdb->prefix . 'wpdo_cold_' . self::POST_TYPE;
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `" . self::$table . "` (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
data LONGTEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
UNIQUE KEY ui_post_id (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$table . "`" );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( "TRUNCATE TABLE `" . self::$table . "`" );
$GLOBALS['_wp_cache'] = [];
}
// ── set / get ─────────────────────────────────────────────────────────────
public function test_set_and_get_single_field(): void {
WPDO_Zone_Cold::set( 100, self::POST_TYPE, 'hp_description', 'Hello World' );
$val = WPDO_Zone_Cold::get( 100, self::POST_TYPE, 'hp_description' );
$this->assertSame( 'Hello World', $val );
}
public function test_get_returns_null_for_missing_key(): void {
WPDO_Zone_Cold::set( 101, self::POST_TYPE, 'hp_description', 'present' );
$val = WPDO_Zone_Cold::get( 101, self::POST_TYPE, 'missing_key' );
$this->assertNull( $val );
}
public function test_get_returns_null_for_missing_post(): void {
$val = WPDO_Zone_Cold::get( 9999, self::POST_TYPE, 'hp_description' );
$this->assertNull( $val );
}
public function test_set_overwrites_existing_value(): void {
WPDO_Zone_Cold::set( 102, self::POST_TYPE, 'hp_website', 'http://old.example.com' );
WPDO_Zone_Cold::set( 102, self::POST_TYPE, 'hp_website', 'http://new.example.com' );
$val = WPDO_Zone_Cold::get( 102, self::POST_TYPE, 'hp_website' );
$this->assertSame( 'http://new.example.com', $val );
}
public function test_set_preserves_other_keys_in_blob(): void {
WPDO_Zone_Cold::set( 103, self::POST_TYPE, 'hp_description', 'Keep me' );
WPDO_Zone_Cold::set( 103, self::POST_TYPE, 'hp_website', 'https://keep.example.com' );
// Update only one key.
WPDO_Zone_Cold::set( 103, self::POST_TYPE, 'hp_website', 'https://updated.example.com' );
$this->assertSame( 'Keep me', WPDO_Zone_Cold::get( 103, self::POST_TYPE, 'hp_description' ) );
$this->assertSame( 'https://updated.example.com', WPDO_Zone_Cold::get( 103, self::POST_TYPE, 'hp_website' ) );
}
// ── set_many / get_blob ───────────────────────────────────────────────────
public function test_set_many_stores_multiple_fields(): void {
WPDO_Zone_Cold::set_many( 200, self::POST_TYPE, [
'hp_description' => 'A great listing',
'hp_website' => 'https://example.com',
'hp_facebook' => 'https://facebook.com/test',
] );
$this->assertSame( 'A great listing', WPDO_Zone_Cold::get( 200, self::POST_TYPE, 'hp_description' ) );
$this->assertSame( 'https://example.com', WPDO_Zone_Cold::get( 200, self::POST_TYPE, 'hp_website' ) );
$this->assertSame( 'https://facebook.com/test', WPDO_Zone_Cold::get( 200, self::POST_TYPE, 'hp_facebook' ) );
}
public function test_get_blob_returns_all_fields(): void {
WPDO_Zone_Cold::set_many( 201, self::POST_TYPE, [
'hp_description' => 'Blob test',
'hp_website' => 'https://blob.example.com',
] );
$blob = WPDO_Zone_Cold::get_blob( 201, self::POST_TYPE );
$this->assertIsArray( $blob );
$this->assertArrayHasKey( 'hp_description', $blob );
$this->assertArrayHasKey( 'hp_website', $blob );
$this->assertSame( 'Blob test', $blob['hp_description'] );
$this->assertSame( 'https://blob.example.com', $blob['hp_website'] );
}
public function test_get_blob_returns_empty_array_for_missing_post(): void {
$blob = WPDO_Zone_Cold::get_blob( 9998, self::POST_TYPE );
$this->assertIsArray( $blob );
$this->assertEmpty( $blob );
}
public function test_set_many_merges_with_existing_blob(): void {
WPDO_Zone_Cold::set_many( 202, self::POST_TYPE, [ 'hp_description' => 'First' ] );
WPDO_Zone_Cold::set_many( 202, self::POST_TYPE, [ 'hp_website' => 'https://merge.example.com' ] );
$this->assertSame( 'First', WPDO_Zone_Cold::get( 202, self::POST_TYPE, 'hp_description' ) );
$this->assertSame( 'https://merge.example.com', WPDO_Zone_Cold::get( 202, self::POST_TYPE, 'hp_website' ) );
}
// ── remove ────────────────────────────────────────────────────────────────
public function test_remove_key_from_blob(): void {
WPDO_Zone_Cold::set_many( 300, self::POST_TYPE, [
'hp_description' => 'Keep me',
'hp_website' => 'https://remove.example.com',
] );
WPDO_Zone_Cold::remove( 300, self::POST_TYPE, 'hp_website' );
$this->assertSame( 'Keep me', WPDO_Zone_Cold::get( 300, self::POST_TYPE, 'hp_description' ) );
$this->assertNull( WPDO_Zone_Cold::get( 300, self::POST_TYPE, 'hp_website' ) );
}
public function test_remove_nonexistent_key_does_not_error(): void {
WPDO_Zone_Cold::set( 301, self::POST_TYPE, 'hp_description', 'Safe' );
WPDO_Zone_Cold::remove( 301, self::POST_TYPE, 'no_such_key' );
// Original key should still be intact.
$this->assertSame( 'Safe', WPDO_Zone_Cold::get( 301, self::POST_TYPE, 'hp_description' ) );
}
// ── delete ────────────────────────────────────────────────────────────────
public function test_delete_removes_row(): void {
global $wpdb;
WPDO_Zone_Cold::set( 400, self::POST_TYPE, 'hp_description', 'To be deleted' );
WPDO_Zone_Cold::delete( 400, self::POST_TYPE );
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$table . "` WHERE post_id = 400" );
$this->assertSame( 0, $count );
$this->assertNull( WPDO_Zone_Cold::get( 400, self::POST_TYPE, 'hp_description' ) );
}
public function test_delete_nonexistent_post_does_not_error(): void {
WPDO_Zone_Cold::delete( 9997, self::POST_TYPE );
$this->assertTrue( true ); // Must not throw.
}
// ── Object Cache ──────────────────────────────────────────────────────────
public function test_get_blob_populates_object_cache(): void {
WPDO_Zone_Cold::set( 500, self::POST_TYPE, 'hp_description', 'Cached value' );
// Clear cache to force a DB read on the next call.
$GLOBALS['_wp_cache'] = [];
// First get: reads from DB, warms the cache.
$val = WPDO_Zone_Cold::get( 500, self::POST_TYPE, 'hp_description' );
$this->assertSame( 'Cached value', $val );
// Cache entry must now exist.
$cached = wp_cache_get( 'cold_500', 'wpdo_cold_itest' );
$this->assertIsArray( $cached );
$this->assertSame( 'Cached value', $cached['hp_description'] );
}
public function test_set_invalidates_object_cache(): void {
WPDO_Zone_Cold::set( 501, self::POST_TYPE, 'hp_description', 'Original' );
// Warm the cache by reading once.
WPDO_Zone_Cold::get( 501, self::POST_TYPE, 'hp_description' );
$this->assertNotFalse( wp_cache_get( 'cold_501', 'wpdo_cold_itest' ) );
// Write a new value — must invalidate the cached blob.
WPDO_Zone_Cold::set( 501, self::POST_TYPE, 'hp_description', 'Updated' );
$this->assertFalse( wp_cache_get( 'cold_501', 'wpdo_cold_itest' ) );
// Subsequent read must return the updated value (from DB).
$val = WPDO_Zone_Cold::get( 501, self::POST_TYPE, 'hp_description' );
$this->assertSame( 'Updated', $val );
}
public function test_delete_invalidates_object_cache(): void {
WPDO_Zone_Cold::set( 502, self::POST_TYPE, 'hp_description', 'Will be deleted' );
// Warm the cache.
WPDO_Zone_Cold::get( 502, self::POST_TYPE, 'hp_description' );
// Delete — must clear cache.
WPDO_Zone_Cold::delete( 502, self::POST_TYPE );
$this->assertFalse( wp_cache_get( 'cold_502', 'wpdo_cold_itest' ) );
}
// ── isolation ─────────────────────────────────────────────────────────────
public function test_different_post_ids_are_independent(): void {
WPDO_Zone_Cold::set( 600, self::POST_TYPE, 'hp_description', 'Post 600' );
WPDO_Zone_Cold::set( 601, self::POST_TYPE, 'hp_description', 'Post 601' );
$this->assertSame( 'Post 600', WPDO_Zone_Cold::get( 600, self::POST_TYPE, 'hp_description' ) );
$this->assertSame( 'Post 601', WPDO_Zone_Cold::get( 601, self::POST_TYPE, 'hp_description' ) );
}
}
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Zone_Hot against real MariaDB.
*
* Creates a dedicated test table (wp_itest_wpdo_hot_hp_listing) in
* setUpBeforeClass() and drops it in tearDownAfterClass(), so the real
* DB is never polluted with test data.
*/
class ZoneHotIntegrationTest extends TestCase {
private const POST_TYPE = 'hp_listing';
private const TABLE = 'wp_itest_wpdo_hot_hp_listing';
// ── Fixture lifecycle ─────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
// DROP + CREATE ensures clean schema even after interrupted prior runs.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`hp_price` decimal(10,2) NOT NULL DEFAULT 0,
`hp_featured` tinyint(1) NOT NULL DEFAULT 0,
`updated_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
PRIMARY KEY (`id`),
UNIQUE KEY `post_id` (`post_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
}
// ── set / get ────────────────────────────────────────────────────────
public function test_set_and_get_single_field(): void {
WPDO_Zone_Hot::set( 1, self::POST_TYPE, 'hp_price', '199.99' );
$val = WPDO_Zone_Hot::get( 1, self::POST_TYPE, 'hp_price' );
$this->assertSame( '199.99', $val );
}
public function test_get_returns_null_for_missing_post(): void {
$val = WPDO_Zone_Hot::get( 9999, self::POST_TYPE, 'hp_price' );
$this->assertNull( $val );
}
public function test_set_overwrites_existing_value(): void {
WPDO_Zone_Hot::set( 2, self::POST_TYPE, 'hp_price', '50.00' );
WPDO_Zone_Hot::set( 2, self::POST_TYPE, 'hp_price', '75.00' );
$val = WPDO_Zone_Hot::get( 2, self::POST_TYPE, 'hp_price' );
$this->assertSame( '75.00', $val );
}
public function test_set_featured_integer_field(): void {
WPDO_Zone_Hot::set( 3, self::POST_TYPE, 'hp_featured', '1' );
$val = WPDO_Zone_Hot::get( 3, self::POST_TYPE, 'hp_featured' );
$this->assertSame( '1', $val );
}
// ── set_many / get_row ───────────────────────────────────────────────
public function test_set_many_stores_multiple_columns(): void {
WPDO_Zone_Hot::set_many( 4, self::POST_TYPE, [
'hp_price' => '299.00',
'hp_featured' => '1',
] );
$row = WPDO_Zone_Hot::get_row( 4, self::POST_TYPE );
$this->assertIsArray( $row );
$this->assertSame( '299.00', $row['hp_price'] );
$this->assertSame( '1', $row['hp_featured'] );
}
public function test_set_many_overwrites_on_second_call(): void {
WPDO_Zone_Hot::set_many( 5, self::POST_TYPE, [ 'hp_price' => '100.00', 'hp_featured' => '0' ] );
WPDO_Zone_Hot::set_many( 5, self::POST_TYPE, [ 'hp_price' => '200.00', 'hp_featured' => '1' ] );
$row = WPDO_Zone_Hot::get_row( 5, self::POST_TYPE );
$this->assertSame( '200.00', $row['hp_price'] );
$this->assertSame( '1', $row['hp_featured'] );
}
public function test_get_row_returns_null_for_missing_post(): void {
$row = WPDO_Zone_Hot::get_row( 9998, self::POST_TYPE );
$this->assertNull( $row );
}
// ── delete ───────────────────────────────────────────────────────────
public function test_delete_removes_row(): void {
WPDO_Zone_Hot::set( 6, self::POST_TYPE, 'hp_price', '42.00' );
$this->assertNotNull( WPDO_Zone_Hot::get( 6, self::POST_TYPE, 'hp_price' ) );
WPDO_Zone_Hot::delete( 6, self::POST_TYPE );
$this->assertNull( WPDO_Zone_Hot::get( 6, self::POST_TYPE, 'hp_price' ) );
}
public function test_delete_nonexistent_post_does_not_error(): void {
// Should complete without throwing.
WPDO_Zone_Hot::delete( 9997, self::POST_TYPE );
$this->assertTrue( true );
}
// ── isolation ────────────────────────────────────────────────────────
public function test_different_post_ids_are_independent(): void {
WPDO_Zone_Hot::set( 10, self::POST_TYPE, 'hp_price', '10.00' );
WPDO_Zone_Hot::set( 11, self::POST_TYPE, 'hp_price', '11.00' );
$this->assertSame( '10.00', WPDO_Zone_Hot::get( 10, self::POST_TYPE, 'hp_price' ) );
$this->assertSame( '11.00', WPDO_Zone_Hot::get( 11, self::POST_TYPE, 'hp_price' ) );
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Zone_Warm against real MariaDB.
*
* Creates a dedicated test table (wp_itest_wpdo_warm) so the real
* production warm table is never touched.
*/
class ZoneWarmIntegrationTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_warm';
// ── Fixture lifecycle ─────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
// DROP + CREATE ensures clean schema even after interrupted prior runs.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`meta_key` varchar(255) NOT NULL DEFAULT \'\',
`meta_value` longtext DEFAULT NULL,
`expires_at` datetime DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
PRIMARY KEY (`id`),
KEY `post_id` (`post_id`),
KEY `expires_at` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
}
// ── set / get ────────────────────────────────────────────────────────
public function test_set_and_get_basic(): void {
WPDO_Zone_Warm::set( 1, 'test_key', 'hello' );
$this->assertSame( 'hello', WPDO_Zone_Warm::get( 1, 'test_key' ) );
}
public function test_get_returns_null_for_missing_key(): void {
$this->assertNull( WPDO_Zone_Warm::get( 9999, 'no_such_key' ) );
}
public function test_set_overwrites_existing_value(): void {
WPDO_Zone_Warm::set( 2, 'counter', '1' );
WPDO_Zone_Warm::set( 2, 'counter', '5' );
$this->assertSame( '5', WPDO_Zone_Warm::get( 2, 'counter' ) );
}
// ── TTL / expiry ─────────────────────────────────────────────────────
public function test_set_with_future_ttl_is_readable(): void {
WPDO_Zone_Warm::set( 3, 'flag', 'active', 3600 ); // expires in 1 hour
$this->assertSame( 'active', WPDO_Zone_Warm::get( 3, 'flag' ) );
}
public function test_expired_entry_returns_null(): void {
global $wpdb;
// Insert directly with a past expiry timestamp.
$wpdb->query(
"INSERT INTO `" . self::TABLE . "` (post_id, meta_key, meta_value, expires_at, created_at)
VALUES (4, 'old_flag', 'gone', '2000-01-01 00:00:00', '2000-01-01 00:00:00')"
);
$this->assertNull( WPDO_Zone_Warm::get( 4, 'old_flag' ) );
}
public function test_null_ttl_entry_never_expires(): void {
WPDO_Zone_Warm::set( 5, 'permanent', 'stays', null );
$this->assertSame( 'stays', WPDO_Zone_Warm::get( 5, 'permanent' ) );
}
// ── delete ───────────────────────────────────────────────────────────
public function test_delete_removes_specific_key(): void {
WPDO_Zone_Warm::set( 6, 'key_a', 'alpha' );
WPDO_Zone_Warm::set( 6, 'key_b', 'beta' );
WPDO_Zone_Warm::delete( 6, 'key_a' );
$this->assertNull( WPDO_Zone_Warm::get( 6, 'key_a' ) );
$this->assertSame( 'beta', WPDO_Zone_Warm::get( 6, 'key_b' ) );
}
public function test_delete_all_removes_all_keys_for_post(): void {
WPDO_Zone_Warm::set( 7, 'x', '1' );
WPDO_Zone_Warm::set( 7, 'y', '2' );
WPDO_Zone_Warm::set( 7, 'z', '3' );
WPDO_Zone_Warm::delete_all( 7 );
$this->assertNull( WPDO_Zone_Warm::get( 7, 'x' ) );
$this->assertNull( WPDO_Zone_Warm::get( 7, 'y' ) );
$this->assertNull( WPDO_Zone_Warm::get( 7, 'z' ) );
}
public function test_delete_all_does_not_affect_other_posts(): void {
WPDO_Zone_Warm::set( 8, 'shared_key', 'post_8' );
WPDO_Zone_Warm::set( 9, 'shared_key', 'post_9' );
WPDO_Zone_Warm::delete_all( 8 );
$this->assertNull( WPDO_Zone_Warm::get( 8, 'shared_key' ) );
$this->assertSame( 'post_9', WPDO_Zone_Warm::get( 9, 'shared_key' ) );
}
// ── purge_expired ────────────────────────────────────────────────────
public function test_purge_expired_removes_stale_entries(): void {
global $wpdb;
// One expired entry.
$wpdb->query(
"INSERT INTO `" . self::TABLE . "` (post_id, meta_key, meta_value, expires_at, created_at)
VALUES (10, 'stale', 'gone', '2000-01-01 00:00:00', '2000-01-01 00:00:00')"
);
// One valid entry.
WPDO_Zone_Warm::set( 10, 'fresh', 'keep', 3600 );
$deleted = WPDO_Zone_Warm::purge_expired();
$this->assertSame( 1, $deleted );
$this->assertNull( WPDO_Zone_Warm::get( 10, 'stale' ) );
$this->assertSame( 'keep', WPDO_Zone_Warm::get( 10, 'fresh' ) );
}
public function test_purge_expired_returns_zero_when_nothing_stale(): void {
WPDO_Zone_Warm::set( 11, 'live', 'value', 3600 );
$this->assertSame( 0, WPDO_Zone_Warm::purge_expired() );
}
// ── get_all ──────────────────────────────────────────────────────────
public function test_get_all_returns_all_valid_keys_for_post(): void {
WPDO_Zone_Warm::set( 12, 'ka', 'va' );
WPDO_Zone_Warm::set( 12, 'kb', 'vb' );
$all = WPDO_Zone_Warm::get_all( 12 );
$this->assertArrayHasKey( 'ka', $all );
$this->assertArrayHasKey( 'kb', $all );
$this->assertSame( 'va', $all['ka'] );
$this->assertSame( 'vb', $all['kb'] );
}
public function test_get_all_excludes_expired_entries(): void {
global $wpdb;
WPDO_Zone_Warm::set( 13, 'live', 'yes' );
$wpdb->query(
"INSERT INTO `" . self::TABLE . "` (post_id, meta_key, meta_value, expires_at, created_at)
VALUES (13, 'dead', 'no', '2000-01-01 00:00:00', '2000-01-01 00:00:00')"
);
$all = WPDO_Zone_Warm::get_all( 13 );
$this->assertArrayHasKey( 'live', $all );
$this->assertArrayNotHasKey( 'dead', $all );
}
}
+604
View File
@@ -0,0 +1,604 @@
<?php
declare(strict_types=1);
/**
* PHPUnit bootstrap for 2meet Data Optimizer integration tests.
*
* Connects to the real MariaDB database using a dedicated test prefix
* (wp_itest_) to avoid collisions with production data.
*
* Required environment variables:
* TMDO_TEST_DB_HOST (default: 127.0.0.1)
* TMDO_TEST_DB_USER (default: dbo)
* TMDO_TEST_DB_PASS (REQUIRED — no fallback)
* TMDO_TEST_DB_NAME (default: wp_wpdo_test)
*
* Back-compat aliases: also accepts WPDO_TEST_DB_* variable names.
*/
require_once dirname( __DIR__, 2 ) . '/vendor/autoload.php';
// ── Constants ──────────────────────────────────────────────────────────────
define( 'ABSPATH', '/fake/wordpress/' );
define( 'TMDO_PATH', dirname( __DIR__, 2 ) . '/' );
define( 'TMDO_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer/' );
define( 'TMDO_FILE', TMDO_PATH . '2meet-data-optimizer.php' );
$_plugin_header = file_get_contents( TMDO_FILE );
if ( false === $_plugin_header || ! preg_match( '/Version:\s*([0-9A-Za-z.\-+]+)/', $_plugin_header, $_ver ) ) {
throw new RuntimeException( 'Cannot read Version from plugin header: ' . TMDO_FILE );
}
define( 'TMDO_VERSION', $_ver[1] );
unset( $_plugin_header, $_ver );
define( 'TMDO_DB_VERSION', '2.0.0' );
define( 'TMDO_IS_SQLITE', false );
define( 'TMDO_IS_MYSQL', true );
if ( ! defined( 'TMDO_TABLE_PREFIX' ) ) { define( 'TMDO_TABLE_PREFIX', 'wpdo_' ); }
if ( ! defined( 'TMDO_CACHE_GROUP' ) ) { define( 'TMDO_CACHE_GROUP', 'wpdo' ); }
if ( ! defined( 'TMDO_MIN_PHP' ) ) { define( 'TMDO_MIN_PHP', '8.1' ); }
if ( ! defined( 'TMDO_MIN_WP' ) ) { define( 'TMDO_MIN_WP', '6.0' ); }
// Back-compat constants.
define( 'WPDO_PLUGIN_DIR', TMDO_PATH );
define( 'WPDO_PLUGIN_URL', TMDO_URL );
define( 'WPDO_PLUGIN_FILE', TMDO_FILE );
define( 'WPDO_VERSION', TMDO_VERSION );
define( 'WPDO_DB_VERSION', TMDO_DB_VERSION );
define( 'WPDO_IS_SQLITE', TMDO_IS_SQLITE );
define( 'WPDO_IS_MYSQL', TMDO_IS_MYSQL );
if ( ! defined( 'WPDO_TABLE_PREFIX' ) ) { define( 'WPDO_TABLE_PREFIX', TMDO_TABLE_PREFIX ); }
if ( ! defined( 'WPDO_CACHE_GROUP' ) ) { define( 'WPDO_CACHE_GROUP', TMDO_CACHE_GROUP ); }
// Tests force module states directly; the FSM guard would reject those jumps.
if ( ! defined( 'TMDO_FSM_GUARD_DISABLED' ) ) { define( 'TMDO_FSM_GUARD_DISABLED', true ); }
if ( ! defined( 'WPDO_FSM_GUARD_DISABLED' ) ) { define( 'WPDO_FSM_GUARD_DISABLED', TMDO_FSM_GUARD_DISABLED ); }
define( 'DAY_IN_SECONDS', 86400 );
define( 'HOUR_IN_SECONDS', 3600 );
define( 'MINUTE_IN_SECONDS', 60 );
if ( ! defined( 'OBJECT' ) ) { define( 'OBJECT', 'OBJECT' ); }
if ( ! defined( 'ARRAY_A' ) ) { define( 'ARRAY_A', 'ARRAY_A' ); }
// ── Database connection ────────────────────────────────────────────────────
$_db_host = getenv( 'TMDO_TEST_DB_HOST' ) ?: ( getenv( 'WPDO_TEST_DB_HOST' ) ?: '127.0.0.1' );
$_db_user = getenv( 'TMDO_TEST_DB_USER' ) ?: ( getenv( 'WPDO_TEST_DB_USER' ) ?: 'dbo' );
$_db_pass = getenv( 'TMDO_TEST_DB_PASS' ) ?: getenv( 'WPDO_TEST_DB_PASS' );
$_db_name = getenv( 'TMDO_TEST_DB_NAME' ) ?: ( getenv( 'WPDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test' );
if ( false === $_db_pass || '' === $_db_pass ) {
throw new RuntimeException(
'Integration tests require TMDO_TEST_DB_PASS (or WPDO_TEST_DB_PASS) environment variable. ' .
'Example: TMDO_TEST_DB_PASS=yourpass ./vendor/bin/phpunit --configuration phpunit-integration.xml'
);
}
$_mysqli_init = new mysqli( $_db_host, $_db_user, $_db_pass );
if ( $_mysqli_init->connect_error ) {
throw new RuntimeException( 'Integration test DB connection failed: ' . $_mysqli_init->connect_error );
}
$_db_name_quoted = '`' . str_replace( '`', '``', $_db_name ) . '`';
if ( ! $_mysqli_init->query( "CREATE DATABASE IF NOT EXISTS {$_db_name_quoted} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" ) ) {
throw new RuntimeException( "Failed to create test database '{$_db_name}': " . $_mysqli_init->error );
}
$_mysqli_init->close();
unset( $_mysqli_init, $_db_name_quoted );
$_mysqli = new mysqli( $_db_host, $_db_user, $_db_pass, $_db_name );
if ( $_mysqli->connect_error ) {
throw new RuntimeException( 'Integration test DB connection failed: ' . $_mysqli->connect_error );
}
$_mysqli->set_charset( 'utf8mb4' );
// ── Real $wpdb ─────────────────────────────────────────────────────────────
global $wpdb;
$wpdb = new class( $_mysqli ) {
private mysqli $db;
public string $prefix = 'wp_itest_';
public string $postmeta = 'wp_itest_postmeta';
public string $posts = 'wp_itest_posts';
public string $options = 'wp_itest_options';
public string $usermeta = 'wp_itest_usermeta';
public string $users = 'wp_itest_users';
public string $terms = 'wp_itest_terms';
public string $termmeta = 'wp_itest_termmeta';
public string $comments = 'wp_itest_comments';
public string $commentmeta = 'wp_itest_commentmeta';
public int $insert_id = 0;
public string $last_error = '';
public function __construct( mysqli $db ) { $this->db = $db; }
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sdf])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
if ( $m[1] === 'd' ) { return (string) (int) $val; }
if ( $m[1] === 'f' ) { return (string) (float) $val; }
return "'" . $this->db->real_escape_string( (string) $val ) . "'";
}, $sql );
}
public function get_var( string $sql ): ?string {
$result = $this->db->query( $sql );
if ( ! $result || ! ( $row = $result->fetch_row() ) ) { return null; }
return $row[0] !== null ? (string) $row[0] : null;
}
public function get_row( string $sql, $output = 'OBJECT' ) {
$result = $this->db->query( $sql );
if ( ! $result ) { return null; }
return ARRAY_A === $output ? ( $result->fetch_assoc() ?: null ) : ( $result->fetch_object() ?: null );
}
public function get_results( string $sql, $output = 'OBJECT' ): array {
$result = $this->db->query( $sql );
if ( ! $result ) { return []; }
$rows = [];
while ( $row = ( ARRAY_A === $output ? $result->fetch_assoc() : $result->fetch_object() ) ) {
$rows[] = $row;
}
return $rows;
}
public function get_col( string $sql, int $col_index = 0 ): array {
$result = $this->db->query( $sql );
if ( ! $result ) { return []; }
$values = [];
while ( $row = $result->fetch_row() ) { $values[] = $row[ $col_index ] ?? null; }
return $values;
}
public function get_charset_collate(): string {
return 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci';
}
public function insert( string $table, array $data, $format = null ): int|false {
$cols = implode( ', ', array_map( fn( $c ) => '`' . $c . '`', array_keys( $data ) ) );
$vals = implode( ', ', array_map(
fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'",
array_values( $data )
) );
$ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" );
if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->insert_id; }
$this->last_error = (string) $this->db->error;
return false;
}
public function update( string $table, array $data, array $where, $format = null, $wf = null ): int|false {
$set = implode( ', ', array_map( fn( $k, $v ) => '`' . $k . '` = ' . ( $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'" ), array_keys( $data ), $data ) );
$cond = implode( ' AND ', array_map( fn( $k, $v ) => '`' . $k . '` = ' . ( $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'" ), array_keys( $where ), $where ) );
$ok = $this->db->query( "UPDATE `{$table}` SET {$set} WHERE {$cond}" );
return $ok ? $this->db->affected_rows : false;
}
public function delete( string $table, array $where, $format = null ): int|false {
$cond = implode( ' AND ', array_map( fn( $k, $v ) => '`' . $k . '` = ' . ( $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'" ), array_keys( $where ), $where ) );
$ok = $this->db->query( "DELETE FROM `{$table}` WHERE {$cond}" );
return $ok ? $this->db->affected_rows : false;
}
public function replace( string $table, array $data, $format = null ): int|false {
$cols = implode( ', ', array_map( fn( $c ) => '`' . $c . '`', array_keys( $data ) ) );
$vals = implode( ', ', array_map( fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'", array_values( $data ) ) );
$ok = $this->db->query( "REPLACE INTO `{$table}` ({$cols}) VALUES ({$vals})" );
if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->db->affected_rows; }
$this->last_error = (string) $this->db->error;
return false;
}
public function esc_like( string $s ): string { return addcslashes( $s, '_%\\' ); }
public function flush(): void {}
public function query( string $sql ): int|bool {
$result = $this->db->query( $sql );
if ( $result instanceof mysqli_result ) { $result->free(); return true; }
if ( false === $result ) { return false; }
return $this->db->affected_rows;
}
};
// ── WordPress function stubs ───────────────────────────────────────────────
if ( ! function_exists( 'trailingslashit' ) ) {
function trailingslashit( string $s ): string { return rtrim( $s, '/' ) . '/'; }
}
if ( ! function_exists( 'sanitize_key' ) ) {
function sanitize_key( string $key ): string { return strtolower( preg_replace( '/[^a-z0-9_\-]/', '', $key ) ); }
}
if ( ! function_exists( 'absint' ) ) {
function absint( $v ): int { return abs( (int) $v ); }
}
if ( ! function_exists( 'wp_unslash' ) ) {
function wp_unslash( $v ) { return is_string( $v ) ? stripslashes( $v ) : $v; }
}
if ( ! function_exists( 'esc_like' ) ) {
function esc_like( string $s ): string { return addcslashes( $s, '_%\\' ); }
}
if ( ! function_exists( 'current_time' ) ) {
function current_time( string $type, bool $gmt = false ): string|int {
if ( 'timestamp' === $type || 'U' === $type ) { return time(); }
return gmdate( 'Y-m-d H:i:s' );
}
}
if ( ! function_exists( 'wp_parse_args' ) ) {
function wp_parse_args( $args, array $defaults = [] ): array {
if ( is_string( $args ) ) { parse_str( $args, $args ); }
return array_merge( $defaults, (array) $args );
}
}
$GLOBALS['_wp_filter_callbacks'] = [];
if ( ! function_exists( 'add_filter' ) ) {
function add_filter( string $hook, $cb, int $p = 10, int $a = 1 ): bool {
$GLOBALS['_wp_filter_callbacks'][ $hook ][] = $cb;
return true;
}
}
if ( ! function_exists( 'add_action' ) ) {
function add_action( string $hook, $cb, int $p = 10, int $a = 1 ): bool {
$GLOBALS['_wp_filter_callbacks'][ $hook ][] = $cb;
return true;
}
}
if ( ! function_exists( 'remove_filter' ) ) {
function remove_filter( string $hook, $cb, int $p = 10 ): bool {
if ( isset( $GLOBALS['_wp_filter_callbacks'][ $hook ] ) ) {
$GLOBALS['_wp_filter_callbacks'][ $hook ] = array_values(
array_filter( $GLOBALS['_wp_filter_callbacks'][ $hook ], fn( $c ) => $c !== $cb )
);
}
return true;
}
}
if ( ! function_exists( 'apply_filters' ) ) {
function apply_filters( string $hook, $value, ...$args ) {
foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) {
$value = $cb( $value, ...$args );
}
return $value;
}
}
if ( ! function_exists( 'do_action' ) ) {
function do_action( string $hook, ...$args ): void {
foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) {
$cb( ...$args );
}
}
}
if ( ! function_exists( 'is_admin' ) ) { function is_admin(): bool { return ! empty( $GLOBALS['_wp_is_admin'] ); } }
if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ): bool { return false; } }
if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } }
if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } }
if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } }
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { return true; } }
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } }
if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } }
if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } }
if ( ! function_exists( 'esc_html' ) ) { function esc_html( $s ): string { return htmlspecialchars( (string) $s, ENT_QUOTES, 'UTF-8' ); } }
if ( ! function_exists( 'esc_attr' ) ) { function esc_attr( string $s ): string { return htmlspecialchars( $s, ENT_QUOTES, 'UTF-8' ); } }
if ( ! function_exists( 'esc_url' ) ) { function esc_url( string $url ): string { return filter_var( $url, FILTER_SANITIZE_URL ) ?: ''; } }
if ( ! function_exists( 'esc_sql' ) ) { function esc_sql( $s ): string { return addslashes( is_string( $s ) ? $s : (string) $s ); } }
if ( ! function_exists( '_doing_it_wrong' ) ) { function _doing_it_wrong( string $fn, string $msg, string $ver ): void {} }
if ( ! function_exists( 'wp_strip_all_tags' ) ) { function wp_strip_all_tags( string $s ): string { return strip_tags( $s ); } }
if ( ! function_exists( 'get_option' ) ) { function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; } }
if ( ! function_exists( 'update_option' ) ) { function update_option( string $key, $value ): bool { $GLOBALS['_wp_options'][ $key ] = $value; return true; } }
if ( ! function_exists( 'delete_option' ) ) { function delete_option( string $key ): bool { unset( $GLOBALS['_wp_options'][ $key ] ); return true; } }
if ( ! function_exists( 'get_transient' ) ) { function get_transient( string $key ) { return $GLOBALS['_wp_transients'][ $key ] ?? false; } }
if ( ! function_exists( 'set_transient' ) ) { function set_transient( string $key, $value, int $exp = 0 ): bool { $GLOBALS['_wp_transients'][ $key ] = $value; return true; } }
if ( ! function_exists( 'delete_transient' ) ) { function delete_transient( string $key ): bool { unset( $GLOBALS['_wp_transients'][ $key ] ); return true; } }
if ( ! function_exists( 'get_post_meta' ) ) { function get_post_meta( int $post_id, string $key = '', bool $single = false ) { return $GLOBALS['_wp_postmeta'][ $post_id ][ $key ] ?? ( $single ? '' : [] ); } }
if ( ! function_exists( 'update_post_meta' ) ) {
function update_post_meta( int $post_id, string $key, $value, $prev = '' ): int|bool {
global $wpdb;
$has_table = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->postmeta ) );
if ( $has_table ) {
$existing = $wpdb->get_var( $wpdb->prepare( "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1", $post_id, $key ) );
if ( $existing ) {
$wpdb->update( $wpdb->postmeta, array( 'meta_value' => is_scalar( $value ) ? (string) $value : maybe_serialize( $value ) ), array( 'meta_id' => $existing ) );
} else {
$wpdb->insert( $wpdb->postmeta, array( 'post_id' => $post_id, 'meta_key' => $key, 'meta_value' => is_scalar( $value ) ? (string) $value : maybe_serialize( $value ) ) );
}
}
$GLOBALS['_wp_postmeta'][ $post_id ][ $key ] = $value;
return true;
}
}
if ( ! function_exists( 'get_user_meta' ) ) { function get_user_meta( int $uid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_usermeta'][ $uid ][ $key ] ?? ( $single ? '' : [] ); } }
if ( ! function_exists( 'update_user_meta' ) ) { function update_user_meta( int $uid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_usermeta'][ $uid ][ $key ] = $value; return true; } }
if ( ! function_exists( 'get_term_meta' ) ) { function get_term_meta( int $tid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_termmeta'][ $tid ][ $key ] ?? ( $single ? '' : [] ); } }
if ( ! function_exists( 'update_term_meta' ) ) { function update_term_meta( int $tid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; return true; } }
if ( ! function_exists( 'get_comment_meta' ) ) { function get_comment_meta( int $cid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); } }
if ( ! function_exists( 'update_comment_meta' ) ) { function update_comment_meta( int $cid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; return true; } }
if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } }
if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } }
if ( ! function_exists( 'is_post_publicly_viewable' ) ) {
function is_post_publicly_viewable( $post_id ): bool {
if ( isset( $GLOBALS['_wp_post_publicly_viewable'][ (int) $post_id ] ) ) { return (bool) $GLOBALS['_wp_post_publicly_viewable'][ (int) $post_id ]; }
return 'publish' === ( $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish' );
}
}
if ( ! function_exists( 'current_user_can' ) ) {
function current_user_can( string $cap, ...$args ): bool {
if ( ! empty( $args ) ) { $key = $cap . ':' . implode( ',', array_map( 'strval', $args ) ); if ( isset( $GLOBALS['_wp_current_user_can'][ $key ] ) ) { return (bool) $GLOBALS['_wp_current_user_can'][ $key ]; } }
return $GLOBALS['_wp_current_user_can'][ $cap ] ?? false;
}
}
if ( ! function_exists( 'get_current_user_id' ) ) { function get_current_user_id(): int { return (int) ( $GLOBALS['_wp_current_user_id'] ?? 0 ); } }
if ( ! function_exists( 'is_multisite' ) ) { function is_multisite(): bool { return (bool) ( $GLOBALS['_wp_is_multisite'] ?? false ); } }
if ( ! function_exists( 'is_super_admin' ) ) { function is_super_admin( ?int $uid = null ): bool { return (bool) ( $GLOBALS['_wp_is_super_admin'] ?? false ); } }
if ( ! function_exists( 'switch_to_blog' ) ) { function switch_to_blog( int $blog_id ): bool { $GLOBALS['_wp_current_blog_id'] = $blog_id; return true; } }
if ( ! function_exists( 'restore_current_blog' ) ) { function restore_current_blog(): bool { unset( $GLOBALS['_wp_current_blog_id'] ); return true; } }
if ( ! function_exists( 'get_sites' ) ) { function get_sites( array $args = [] ): array { return $GLOBALS['_wp_sites'] ?? []; } }
if ( ! function_exists( 'is_plugin_active_for_network' ) ) { function is_plugin_active_for_network( string $plugin ): bool { return (bool) ( $GLOBALS['_wp_plugin_active_for_network'][ $plugin ] ?? false ); } }
if ( ! function_exists( 'is_plugin_active' ) ) { function is_plugin_active( string $plugin ): bool { return false; } }
if ( ! function_exists( 'deactivate_plugins' ) ) { function deactivate_plugins( $plugin, bool $silent = false ): void {} }
if ( ! function_exists( 'plugin_basename' ) ) { function plugin_basename( string $file ): string { return basename( dirname( $file ) ) . '/' . basename( $file ); } }
if ( ! function_exists( 'wp_generate_password' ) ) { function wp_generate_password( int $len = 12, bool $special = true ): string { return substr( str_replace( [ '/', '+', '=' ], '', base64_encode( random_bytes( $len ) ) ), 0, $len ); } }
if ( ! function_exists( 'wp_json_encode' ) ) { function wp_json_encode( $data, int $flags = 0 ): string|false { return json_encode( $data, $flags ); } }
if ( ! function_exists( 'wp_rand' ) ) { function wp_rand( int $min = 0, int $max = 0 ): int { return random_int( $min, $max ?: PHP_INT_MAX ); } }
if ( ! function_exists( 'is_wp_error' ) ) { function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; } }
if ( ! function_exists( 'wp_verify_nonce' ) ) { function wp_verify_nonce( $nonce, string $action = '' ) { return $GLOBALS['_wp_valid_nonces'][ (string) $nonce ] ?? false; } }
if ( ! function_exists( 'wp_create_nonce' ) ) { function wp_create_nonce( string $action = '' ): string { $nonce = 'test_nonce_' . md5( $action ); $GLOBALS['_wp_valid_nonces'][ $nonce ] = 1; return $nonce; } }
if ( ! function_exists( 'wp_die' ) ) { function wp_die( $message = '' ): void { throw new RuntimeException( is_string( $message ) ? $message : 'wp_die' ); } }
if ( ! function_exists( 'maybe_serialize' ) ) { function maybe_serialize( $data ) { return is_array( $data ) || is_object( $data ) ? serialize( $data ) : $data; } }
if ( ! function_exists( 'maybe_unserialize' ) ) { function maybe_unserialize( $value ) { if ( ! is_string( $value ) ) { return $value; } $u = @unserialize( $value ); return ( false !== $u || 'b:0;' === $value ) ? $u : $value; } }
if ( ! function_exists( 'get_bloginfo' ) ) { function get_bloginfo( string $key ): string { return 'version' === $key ? '6.9.4' : ''; } }
if ( ! function_exists( 'sanitize_title' ) ) { function sanitize_title( string $title ): string { return strtolower( preg_replace( '/[^a-z0-9-]+/i', '-', trim( $title ) ) ); } }
if ( ! function_exists( 'taxonomy_exists' ) ) {
function taxonomy_exists( string $taxonomy ): bool {
if ( isset( $GLOBALS['_taxonomy_exists_override'] ) ) { return (bool) $GLOBALS['_taxonomy_exists_override']; }
return in_array( $taxonomy, [ 'category', 'post_tag', 'listing_category', 'listing_tag' ], true );
}
}
if ( ! function_exists( 'clean_term_cache' ) ) { function clean_term_cache( $ids, string $taxonomy = '', bool $clean_taxonomy = true ): void {} }
if ( ! function_exists( 'get_taxonomies' ) ) {
function get_taxonomies( array $args = [], string $output = 'names' ): array {
$taxonomies = [ 'category', 'post_tag', 'listing_category', 'listing_tag' ];
if ( 'objects' === $output ) {
$out = [];
foreach ( $taxonomies as $slug ) { $out[ $slug ] = (object) [ 'name' => $slug, 'labels' => (object) [ 'singular_name' => ucfirst( str_replace( '_', ' ', $slug ) ) ] ]; }
return $out;
}
return $taxonomies;
}
}
if ( ! function_exists( 'is_serialized' ) ) { function is_serialized( $data ): bool { return is_string( $data ) && strlen( $data ) >= 4 && in_array( $data[0], [ 'a', 's', 'i', 'd', 'b', 'O', 'N' ], true ) && str_ends_with( $data, ';' ); } }
if ( ! function_exists( 'wp_upload_dir' ) ) {
function wp_upload_dir( $time = null, $create_dir = true, $refresh_cache = false ): array {
$upload_path = sys_get_temp_dir() . '/wp-uploads-test';
return [
'path' => $upload_path,
'url' => 'http://localhost/wp-content/uploads',
'subdir' => '',
'basedir' => $upload_path,
'baseurl' => 'http://localhost/wp-content/uploads',
'error' => false,
];
}
}
if ( ! function_exists( 'wp_mkdir_p' ) ) {
function wp_mkdir_p( string $dir ): bool {
if ( is_dir( $dir ) ) { return true; }
return mkdir( $dir, 0777, true );
}
}
$GLOBALS['_wp_cache'] = [];
if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } }
if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } }
if ( ! function_exists( 'wp_cache_delete' ) ) { function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; } }
if ( ! class_exists( 'WP_Query' ) ) {
class WP_Query {
private array $vars = [];
public function get( string $key, $default = '' ) { return $this->vars[ $key ] ?? $default; }
public function set( string $key, $value ): void { $this->vars[ $key ] = $value; }
}
}
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
private string $code;
private string $message;
public function __construct( string $code = '', string $message = '' ) { $this->code = $code; $this->message = $message; }
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
}
}
if ( ! class_exists( 'WP_REST_Request' ) ) {
class WP_REST_Request {
private array $params = []; private array $headers = [];
public function __construct( string $method = 'GET', string $route = '' ) {}
public function get_param( string $key ) { return $this->params[ $key ] ?? null; }
public function set_param( string $key, $value ): void { $this->params[ $key ] = $value; }
public function get_header( string $key ): ?string { return $this->headers[ strtolower( $key ) ] ?? null; }
public function set_header( string $key, string $value ): void { $this->headers[ strtolower( $key ) ] = $value; }
}
}
if ( ! class_exists( 'WP_REST_Response' ) ) {
class WP_REST_Response {
private $data; private int $status; private array $headers = [];
public function __construct( $data = null, int $status = 200 ) { $this->data = $data; $this->status = $status; }
public function get_data() { return $this->data; }
public function get_status(): int { return $this->status; }
public function header( string $k, string $v ): void { $this->headers[ $k ] = $v; }
public function get_headers(): array { return $this->headers; }
}
}
if ( ! class_exists( 'WP_REST_Server' ) ) {
class WP_REST_Server { const READABLE = 'GET'; const CREATABLE = 'POST'; }
}
if ( ! function_exists( 'register_rest_route' ) ) { function register_rest_route( string $ns, string $route, array $args ): bool { return true; } }
if ( ! function_exists( 'dbDelta' ) ) {
function dbDelta( string $sql ): array {
global $wpdb;
$sql_idempotent = preg_replace( '/^CREATE TABLE/i', 'CREATE TABLE IF NOT EXISTS', trim( $sql ), 1 );
$wpdb->query( $sql_idempotent );
return [];
}
}
if ( ! function_exists( 'wp_insert_post' ) ) {
function wp_insert_post( array $postarr, bool $wp_error = false ) {
global $wpdb;
$now = current_time( 'mysql' );
$defaults = [ 'post_title' => '', 'post_type' => 'post', 'post_status' => 'publish', 'post_content' => '', 'post_excerpt' => '', 'post_content_filtered' => '', 'to_ping' => '', 'pinged' => '', 'post_date' => $now, 'post_date_gmt' => $now, 'post_modified' => $now, 'post_modified_gmt' => $now, 'post_name' => '', 'guid' => '' ];
$row = array_merge( $defaults, $postarr );
if ( '' === $row['post_name'] ) { $row['post_name'] = sanitize_title( (string) $row['post_title'] ); }
$ok = $wpdb->insert( $wpdb->posts, $row );
return $ok ? (int) $wpdb->insert_id : ( $wp_error ? new WP_Error( 'insert_failed', 'Insert failed' ) : 0 );
}
}
if ( ! function_exists( 'wp_insert_term' ) ) {
function wp_insert_term( string $term, string $taxonomy, array $args = [] ) {
global $wpdb;
$slug = $args['slug'] ?? sanitize_title( $term );
$ok = $wpdb->insert( $wpdb->terms, [ 'name' => $term, 'slug' => $slug, 'term_group' => 0 ] );
if ( ! $ok ) { return new WP_Error( 'insert_failed', 'terms insert failed' ); }
$term_id = (int) $wpdb->insert_id;
$wpdb->insert( $wpdb->prefix . 'term_taxonomy', [ 'term_id' => $term_id, 'taxonomy' => $taxonomy, 'description' => '', 'parent' => 0, 'count' => 0 ] );
return [ 'term_id' => $term_id, 'term_taxonomy_id' => (int) $wpdb->insert_id ];
}
}
if ( ! function_exists( 'wp_insert_comment' ) ) {
function wp_insert_comment( array $data ) {
global $wpdb;
$ok = $wpdb->insert( $wpdb->comments, [
'comment_post_ID' => (int) ( $data['comment_post_ID'] ?? 0 ),
'comment_author' => (string) ( $data['comment_author'] ?? '' ),
'comment_author_email' => (string) ( $data['comment_author_email'] ?? '' ),
'comment_author_url' => (string) ( $data['comment_author_url'] ?? '' ),
'comment_author_IP' => (string) ( $data['comment_author_IP'] ?? '127.0.0.1' ),
'comment_date' => (string) ( $data['comment_date'] ?? gmdate( 'Y-m-d H:i:s' ) ),
'comment_date_gmt' => (string) ( $data['comment_date_gmt'] ?? gmdate( 'Y-m-d H:i:s' ) ),
'comment_content' => (string) ( $data['comment_content'] ?? '' ),
'comment_karma' => (int) ( $data['comment_karma'] ?? 0 ),
'comment_approved' => (string) ( $data['comment_approved'] ?? '1' ),
'comment_agent' => (string) ( $data['comment_agent'] ?? '' ),
'comment_type' => (string) ( $data['comment_type'] ?? 'comment' ),
'comment_parent' => (int) ( $data['comment_parent'] ?? 0 ),
'user_id' => (int) ( $data['user_id'] ?? 0 ),
] );
if ( ! $ok ) { return false; }
return (int) $wpdb->insert_id;
}
}
if ( ! class_exists( 'WP_CLI' ) ) {
class WP_CLI {
public static function log( string $msg ): void {}
public static function warning( string $msg ): void {}
public static function success( string $msg ): void {}
public static function error( string $msg ): void {}
public static function add_command( string $name, $class ): void {}
}
}
// ── Load plugin classes ────────────────────────────────────────────────────
require_once TMDO_PATH . 'includes/class-tmdo-capability.php';
require_once TMDO_PATH . 'includes/class-tmdo-crypto.php';
require_once TMDO_PATH . 'includes/class-tmdo-safe-unserialize.php';
require_once TMDO_PATH . 'includes/class-tmdo-db.php';
require_once TMDO_PATH . 'includes/class-tmdo-logger.php';
require_once TMDO_PATH . 'includes/class-tmdo-feature-flags.php';
require_once TMDO_PATH . 'includes/class-tmdo-sqlite-compat.php';
require_once TMDO_PATH . 'includes/class-tmdo-installer.php';
require_once TMDO_PATH . 'includes/class-tmdo-schema-registry.php';
require_once TMDO_PATH . 'includes/class-tmdo-custom-table-registry.php';
require_once TMDO_PATH . 'includes/trait-tmdo-anti-eav-aware.php';
require_once TMDO_PATH . 'includes/class-tmdo-hook-bus-bridge.php';
require_once TMDO_PATH . 'includes/class-tmdo-conflict-monitor.php';
require_once TMDO_PATH . 'includes/class-tmdo-compatibility.php';
require_once TMDO_PATH . 'includes/interceptors/class-tmdo-interceptor-base.php';
require_once TMDO_PATH . 'includes/interceptors/class-tmdo-sync-bridge.php';
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-hot.php';
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-warm.php';
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-cold.php';
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-archive.php';
require_once TMDO_PATH . 'includes/query/class-tmdo-query-interceptor-base.php';
require_once TMDO_PATH . 'includes/query/class-tmdo-query-router.php';
require_once TMDO_PATH . 'includes/query/class-tmdo-post-query-router.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-base.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-engine.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-hot-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-warm-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-cold-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-archive-migration.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-term-comment-garbage-filter.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-term-comment-misc-bucket.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-member-fields.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-post-fields.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-points-manager.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-demo-entity-counter.php';
require_once TMDO_PATH . 'includes/class-tmdo-cache-layer.php';
require_once TMDO_PATH . 'includes/class-tmdo-zone-classifier.php';
require_once TMDO_PATH . 'includes/class-tmdo-api.php';
require_once TMDO_PATH . 'includes/class-tmdo-v2-upgrader.php';
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-manager.php';
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-writer.php';
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-reader.php';
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-pruner.php';
require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php';
require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-cache-orchestrator.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-query-compiler.php';
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/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';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-term.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-comment.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-commentmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-term-comment-shadow-verifier.php';
require_once TMDO_PATH . 'includes/class-tmdo-term-comment-backfill.php';
require_once TMDO_PATH . 'includes/class-tmdo-term-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-comment-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php';
require_once TMDO_PATH . 'includes/class-tmdo-core.php';
// Back-compat aliases (WPDO_* → TMDO_*).
require_once TMDO_PATH . 'includes/class-tmdo-back-compat.php';
// FSM Guard bypass for integration tests (real DB transitions should work).
if ( ! function_exists( '__return_true' ) ) {
function __return_true(): bool { return true; }
}
add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
// TMDO_Listing_Stats moved to HP AddOn; stub here for tests that reference it.
if ( ! class_exists( 'TMDO_Listing_Stats' ) ) {
class TMDO_Listing_Stats {
public static function register(): void {}
public static function get_view_count( int $post_id ): int { return 0; }
public static function increment_view( int $post_id, string $ip = '' ): int { return 0; }
public static function is_rate_limited( int $post_id, string $ip ): bool { return false; }
public static function flush_views_to_postmeta(): int { return 0; }
}
class_alias( 'TMDO_Listing_Stats', 'WPDO_Listing_Stats' );
}