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
+140
View File
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
if ( ! function_exists( '__' ) ) {
function __( string $text, string $domain = 'default' ): string {
return $text;
}
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-feature-flags.php';
require_once dirname( __DIR__, 3 ) . '/includes/advisor/class-tmdo-fsm-advisor.php';
/**
* Unit tests for WPDO_FSM_Advisor (v2.4.0 M12).
*
* Pure-logic tests — uses option-driven state injection (no real DB).
*/
class FSMAdvisorTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public string $postmeta = 'wp_postmeta';
public string $options = 'wp_options';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%[sd]/', function() use ( &$i, $args ) {
return (string) ( $args[ $i++ ] ?? '?' );
}, $sql );
}
public function get_var( string $sql ) { return '0'; } // shadow_diffs table absent
public function get_results( string $sql, $output = ARRAY_A ): array { return array(); }
};
}
private function set_module_state( string $module, string $state, ?int $days_ago = null ): void {
// Set FSM state.
$flags = (array) get_option( 'wpdo_features', array() );
$flags[ $module ] = $state;
update_option( 'wpdo_features', $flags, false );
// Reset Feature_Flags request cache via reflection.
$ref = new ReflectionClass( WPDO_Feature_Flags::class );
$prop = $ref->getProperty( 'cache' );
$prop->setAccessible( true );
$prop->setValue( null, null );
if ( null !== $days_ago ) {
$entered = (array) get_option( 'wpdo_fsm_state_entered', array() );
$entered[ $module ] = array(
'state' => $state,
'entered_at' => gmdate( 'Y-m-d H:i:s', time() - $days_ago * 86400 ),
);
update_option( 'wpdo_fsm_state_entered', $entered, false );
}
}
public function test_idle_state_returns_HOLD(): void {
$this->set_module_state( 'reviews', 'idle' );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'HOLD', $advice['action'] );
$this->assertSame( 'info', $advice['level'] );
}
public function test_complete_state_returns_HOLD(): void {
$this->set_module_state( 'reviews', 'complete' );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'HOLD', $advice['action'] );
}
public function test_dual_write_with_short_soak_returns_WAIT(): void {
// In dual_write 0 days < 1 day min soak.
$this->set_module_state( 'reviews', 'dual_write', 0 );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'WAIT', $advice['action'] );
$this->assertGreaterThanOrEqual( 1, $advice['days_remaining'] );
}
public function test_dual_write_after_min_soak_returns_PROMOTE(): void {
$this->set_module_state( 'reviews', 'dual_write', 2 );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'PROMOTE', $advice['action'] );
$this->assertSame( 'backfill', $advice['next_state'] );
}
public function test_verify_with_long_soak_returns_PROMOTE(): void {
// verify needs 7 days; 10 days = should promote.
$this->set_module_state( 'reviews', 'verify', 10 );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'PROMOTE', $advice['action'] );
$this->assertSame( 'cutover', $advice['next_state'] );
}
public function test_verify_with_short_soak_returns_WAIT(): void {
$this->set_module_state( 'reviews', 'verify', 3 );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'WAIT', $advice['action'] );
$this->assertSame( 4, $advice['days_remaining'] );
}
public function test_cleanup_with_short_wash_returns_WAIT(): void {
$this->set_module_state( 'reviews', 'cleanup', 1 );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'WAIT', $advice['action'] );
$this->assertSame( 2, $advice['days_remaining'] );
}
public function test_cleanup_after_wash_returns_PROMOTE(): void {
$this->set_module_state( 'reviews', 'cleanup', 5 );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'PROMOTE', $advice['action'] );
$this->assertSame( 'complete', $advice['next_state'] );
}
public function test_advice_metrics_include_state_and_soak(): void {
$this->set_module_state( 'reviews', 'dual_write', 5 );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'dual_write', $advice['metrics']['state'] );
$this->assertSame( 5, $advice['metrics']['days_in_state'] );
$this->assertSame( 1, $advice['metrics']['min_soak_days'] );
}
public function test_advise_all_returns_map_for_all_modules(): void {
$advice = WPDO_FSM_Advisor::advise_all();
$this->assertNotEmpty( $advice );
// All known HPCT + zone modules should be present (default idle).
foreach ( WPDO_Feature_Flags::HPCT_MODULES as $m ) {
$this->assertArrayHasKey( $m, $advice );
}
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
if ( ! function_exists( '__' ) ) {
function __( string $text, string $domain = 'default' ): string { return $text; }
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-feature-flags.php';
require_once dirname( __DIR__, 3 ) . '/includes/advisor/class-tmdo-fsm-advisor.php';
require_once dirname( __DIR__, 3 ) . '/includes/diagnostic/class-tmdo-health-cron.php';
require_once dirname( __DIR__, 3 ) . '/includes/advisor/class-tmdo-fsm-automator.php';
/**
* Unit tests for WPDO_FSM_Automator (v2.5.0 M13).
*/
class FSMAutomatorTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
// Reset Feature_Flags cache.
$ref = new ReflectionClass( WPDO_Feature_Flags::class );
$prop = $ref->getProperty( 'cache' );
$prop->setAccessible( true );
$prop->setValue( null, null );
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public string $postmeta = 'wp_postmeta';
public string $options = 'wp_options';
public function prepare( string $sql, ...$args ): string { return $sql; }
public function get_var( string $sql ) { return '0'; }
public function get_results( string $sql, $output = ARRAY_A ): array { return array(); }
};
}
public function test_default_disabled(): void {
$this->assertFalse( WPDO_FSM_Automator::is_enabled() );
}
public function test_run_returns_disabled_when_off(): void {
$result = WPDO_FSM_Automator::run();
$this->assertFalse( $result['ok'] );
$this->assertContains( 'automator disabled', $result['errors'] );
}
public function test_run_blocked_by_critical_cool_off(): void {
// Enable + simulate critical alert in last run.
update_option( 'wpdo_automator_enabled', '1', false );
update_option( WPDO_Health_Cron::OPTION_LAST_RUN, array(
'critical_count' => 2,
'ran_at' => gmdate( 'Y-m-d H:i:s' ),
), false );
$result = WPDO_FSM_Automator::run();
$this->assertFalse( $result['ok'] );
$this->assertStringContainsString( 'cool-off', $result['errors'][0] );
}
public function test_run_proceeds_when_no_critical(): void {
update_option( 'wpdo_automator_enabled', '1', false );
update_option( WPDO_Health_Cron::OPTION_LAST_RUN, array(
'critical_count' => 0,
'ran_at' => gmdate( 'Y-m-d H:i:s' ),
), false );
$result = WPDO_FSM_Automator::run();
$this->assertTrue( $result['ok'] );
// Most modules will be in idle and Advisor recommends WAIT (soak time);
// expected that 0 actions are executed in baseline test.
$this->assertGreaterThanOrEqual( 0, $result['executed'] );
}
public function test_blacklist_skips_module(): void {
update_option( 'wpdo_automator_enabled', '1', false );
update_option( 'wpdo_automator_blacklist', array( 'reviews', 'wc_orders' ), false );
update_option( WPDO_Health_Cron::OPTION_LAST_RUN, array(
'critical_count' => 0,
'ran_at' => gmdate( 'Y-m-d H:i:s' ),
), false );
$blacklist = WPDO_FSM_Automator::blacklist();
$this->assertContains( 'reviews', $blacklist );
$this->assertContains( 'wc_orders', $blacklist );
$result = WPDO_FSM_Automator::run();
$this->assertTrue( $result['ok'] );
// Skipped at least the 2 blacklisted ones.
$this->assertGreaterThanOrEqual( 2, $result['skipped'] );
}
public function test_forbidden_transitions_constant(): void {
$ref = new ReflectionClass( WPDO_FSM_Automator::class );
$constant = $ref->getReflectionConstant( 'FORBIDDEN_TRANSITIONS' );
$this->assertNotNull( $constant );
$value = $constant->getValue();
$this->assertContains( array( 'verify', 'cutover' ), $value );
$this->assertContains( array( 'cutover', 'cleanup' ), $value );
$this->assertContains( array( 'cleanup', 'complete' ), $value );
}
public function test_options_keys_match_admin_form(): void {
$this->assertSame( 'wpdo_automator_enabled', WPDO_FSM_Automator::OPT_ENABLED );
$this->assertSame( 'wpdo_automator_blacklist', WPDO_FSM_Automator::OPT_BLACKLIST );
$this->assertSame( 'wpdo_automator_last_action', WPDO_FSM_Automator::OPT_LAST_ACTION );
}
public function test_last_actions_returns_array(): void {
$this->assertSame( array(), WPDO_FSM_Automator::last_actions() );
update_option( 'wpdo_automator_last_action', array( 'reviews' => '2026-04-28 04:30:00' ), false );
$last = WPDO_FSM_Automator::last_actions();
$this->assertSame( '2026-04-28 04:30:00', $last['reviews'] );
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
if ( ! function_exists( '__' ) ) {
function __( string $text, string $domain = 'default' ): string { return $text; }
}
if ( ! function_exists( 'number_format_i18n' ) ) {
function number_format_i18n( $n, int $decimals = 0 ): string {
return number_format( (float) $n, $decimals );
}
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-feature-flags.php';
require_once dirname( __DIR__, 3 ) . '/includes/advisor/class-tmdo-module-rules.php';
require_once dirname( __DIR__, 3 ) . '/includes/advisor/class-tmdo-module-detector.php';
/**
* Unit tests for WPDO_Module_Rules + WPDO_Module_Detector (v2.5.0 M16).
*
* Pure-logic tests using mocked WPDO_Compatibility (via dynamic class
* substitution where needed) and option-driven post counts.
*/
class ModuleDetectorTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
// Reset Feature_Flags cache.
$ref = new ReflectionClass( WPDO_Feature_Flags::class );
$prop = $ref->getProperty( 'cache' );
$prop->setAccessible( true );
$prop->setValue( null, null );
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public string $posts = 'wp_posts';
public string $postmeta = 'wp_postmeta';
public string $options = 'wp_options';
public array $count_overrides = array();
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%[sd]/', function() use ( &$i, $args ) {
return is_string( $args[ $i ] ?? null ) ? "'" . $args[ $i++ ] . "'" : (string) ( $args[ $i++ ] ?? '?' );
}, $sql );
}
public function get_var( string $sql ) {
// Mock by inspecting SQL pattern.
if ( str_contains( $sql, "post_type =" ) ) {
if ( preg_match( "/post_type = '([^']+)'/", $sql, $m ) ) {
return (string) ( $this->count_overrides[ $m[1] ] ?? 0 );
}
}
if ( str_contains( $sql, "post_status = 'trash'" ) ) {
return (string) ( $this->count_overrides['__trash__'] ?? 0 );
}
return '0';
}
public function get_results( string $sql, $output = ARRAY_A ): array { return array(); }
};
}
public function test_module_rules_known_modules_count(): void {
$modules = WPDO_Module_Rules::known_modules();
$this->assertGreaterThanOrEqual( 14, count( $modules ), '預期 ≥ 14 個 module9 HPCT + 6 zone - 1 dup' );
$this->assertContains( 'reviews', $modules );
$this->assertContains( 'warm', $modules );
$this->assertContains( 'archive', $modules );
$this->assertContains( 'hot_hp_listing', $modules );
}
public function test_module_rule_for_module_returns_array(): void {
$rule = WPDO_Module_Rules::for_module( 'reviews' );
$this->assertIsArray( $rule );
$this->assertArrayHasKey( 'compat_required', $rule );
$this->assertArrayHasKey( 'description', $rule );
}
public function test_module_rule_unknown_module_returns_null(): void {
$this->assertNull( WPDO_Module_Rules::for_module( 'totally_fake_module_xyz' ) );
}
public function test_detector_skip_when_module_not_idle(): void {
// Set reviews to dual_write.
WPDO_Feature_Flags::set( 'reviews', 'dual_write' );
$r = WPDO_Module_Detector::detect_one( 'reviews' );
$this->assertSame( 'skip', $r['recommendation'] );
$this->assertFalse( $r['available'] );
$this->assertNotEmpty( $r['blockers'] );
$this->assertSame( 'dual_write', $r['current_state'] );
}
public function test_detector_warm_module_recommends_for_any_site(): void {
// warm module's compat_required is empty + no post_type.
$r = WPDO_Module_Detector::detect_one( 'warm' );
$this->assertTrue( $r['available'] );
$this->assertSame( 'enable', $r['recommendation'] );
$this->assertGreaterThan( 0, $r['confidence'] );
$this->assertNotEmpty( $r['suggested_action'] );
$this->assertSame( 'dual_write', $r['suggested_action']['to_state'] );
}
public function test_detector_archive_blocked_when_no_trashed_posts(): void {
// trashed = 0, threshold = 50.
global $wpdb;
$wpdb->count_overrides['__trash__'] = 0;
$r = WPDO_Module_Detector::detect_one( 'archive' );
$this->assertSame( 'wait', $r['recommendation'] );
$this->assertFalse( $r['available'] );
}
public function test_detector_archive_passes_when_enough_trashed(): void {
global $wpdb;
$wpdb->count_overrides['__trash__'] = 100;
$r = WPDO_Module_Detector::detect_one( 'archive' );
$this->assertSame( 'enable', $r['recommendation'] );
$this->assertTrue( $r['available'] );
}
public function test_detector_hivepress_required_blocks_when_inactive(): void {
// reviews requires hivepress; Compatibility class isn't loaded in this test.
// Without Compatibility class, the helper returns the full required list as missing.
$r = WPDO_Module_Detector::detect_one( 'reviews' );
$this->assertSame( 'skip', $r['recommendation'] );
$this->assertNotEmpty( $r['blockers'] );
}
public function test_get_actionable_filters_by_confidence(): void {
$actionable = WPDO_Module_Detector::get_actionable( 0.5 );
$this->assertIsArray( $actionable );
// Each result should have available=true and recommendation=enable.
foreach ( $actionable as $module => $r ) {
$this->assertTrue( $r['available'], "$module should be available" );
$this->assertSame( 'enable', $r['recommendation'] );
$this->assertGreaterThanOrEqual( 0.5, $r['confidence'] );
}
}
public function test_get_actionable_sorts_by_confidence_desc(): void {
$actionable = WPDO_Module_Detector::get_actionable( 0.0 );
$last_confidence = 1.0;
foreach ( $actionable as $r ) {
$this->assertLessThanOrEqual( $last_confidence, (float) $r['confidence'] );
$last_confidence = (float) $r['confidence'];
}
}
public function test_detect_all_returns_entry_for_each_known_module(): void {
$all = WPDO_Module_Detector::detect_all( true );
$known = WPDO_Module_Rules::known_modules();
foreach ( $known as $m ) {
$this->assertArrayHasKey( $m, $all, "Detector should return entry for $m" );
}
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for TMDO back-compat layer.
*
* Verifies that WPDO_* aliases for classes, traits, and interfaces all resolve
* correctly so sister plugins compiled against old names continue to work.
*/
class BackCompatTest extends TestCase {
// ── Trait alias ──────────────────────────────────────────────────────────
public function test_wpdo_anti_eav_aware_trait_exists(): void {
$this->assertTrue( trait_exists( 'WPDO_Anti_EAV_Aware' ), 'WPDO_Anti_EAV_Aware trait alias must exist' );
}
public function test_class_using_wpdo_anti_eav_aware_is_valid(): void {
// Verify a class can `use WPDO_Anti_EAV_Aware` without fatal error.
$obj = new class {
use WPDO_Anti_EAV_Aware;
};
// trait_exists check via class_uses — PHP doesn't support instanceof for traits.
$this->assertArrayHasKey( 'WPDO_Anti_EAV_Aware', class_uses( $obj ) );
}
// ── Interface alias ───────────────────────────────────────────────────────
public function test_wpdo_entity_adapter_interface_exists(): void {
$this->assertTrue(
interface_exists( 'WPDO_Entity_Adapter_Interface' ),
'WPDO_Entity_Adapter_Interface must exist as a back-compat alias'
);
}
public function test_tmdo_adapter_post_implements_wpdo_interface(): void {
// An object implementing TMDO_Entity_Adapter_Interface must also
// satisfy `instanceof WPDO_Entity_Adapter_Interface`.
$adapter = new TMDO_Adapter_Post();
$this->assertInstanceOf( 'WPDO_Entity_Adapter_Interface', $adapter );
}
// ── DB version consistency ────────────────────────────────────────────────
public function test_tmdo_db_version_constant_matches_installer_schema(): void {
// TMDO_DB_VERSION (plugin header constant) must equal TMDO_Installer::SCHEMA_VERSION
// (private). If they diverge, maybe_upgrade() either never fires or fires every boot.
$ref = new ReflectionClass( TMDO_Installer::class );
$schema_version = $ref->getConstant( 'SCHEMA_VERSION' );
$this->assertSame( TMDO_DB_VERSION, $schema_version,
'TMDO_DB_VERSION constant must match TMDO_Installer::SCHEMA_VERSION' );
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Cache_Layer — Object Cache integration for Zone C (Cold).
*/
class CacheLayerTest extends TestCase {
/** Rows returned by get_results() for the bulk-fetch query. */
public static array $db_rows = [];
protected function setUp(): void {
self::$db_rows = [];
$GLOBALS['_wp_cache'] = [];
// Reset Schema Registry singleton.
$ref = new ReflectionClass( WPDO_Schema_Registry::class );
$ref->getProperty( 'instance' )->setValue( null, null );
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
public function get_var( string $sql ): ?string { return null; }
public function get_row( string $sql, $output = OBJECT ) { return null; }
public function insert( string $table, array $data, $format = null ): int|false { return 1; }
public function update( string $table, array $data, array $where, $f = null, $wf = null ): int|false { return 1; }
public function delete( string $table, array $where, $format = null ): int|false { return 1; }
public function query( string $sql ): int|bool { return 1; }
public function get_results( string $sql, $output = OBJECT ): array {
return CacheLayerTest::$db_rows;
}
};
}
// ── Helper: register cold field ──────────────────────────────────────────
private function register_cold_field( string $post_type = 'hp_listing', string $meta_key = 'hp_description' ): void {
WPDO_Schema_Registry::instance()->register( 'test', [
'post_type' => $post_type,
'meta_key' => $meta_key,
'zone' => 'cold',
] );
}
// ── prefetch() ───────────────────────────────────────────────────────────
public function test_prefetch_returns_early_for_empty_post_ids(): void {
$this->register_cold_field();
WPDO_Cache_Layer::prefetch( [], 'hp_listing' );
$this->assertEmpty( $GLOBALS['_wp_cache'] );
}
public function test_prefetch_returns_early_when_no_cold_keys_for_type(): void {
// 'unknown_type' has no registered cold fields.
WPDO_Cache_Layer::prefetch( [ 1, 2, 3 ], 'unknown_type' );
$this->assertEmpty( $GLOBALS['_wp_cache'] );
}
public function test_prefetch_skips_already_cached_post_ids(): void {
$this->register_cold_field();
$group = 'wpdo_cold_hp_listing';
// Pre-warm cache for post 1.
$GLOBALS['_wp_cache'][ $group ]['cold_1'] = [ 'hp_description' => 'cached' ];
// DB returns no extra rows — all were already cached.
self::$db_rows = [];
WPDO_Cache_Layer::prefetch( [ 1 ], 'hp_listing' );
// Cache should remain unchanged (no new entry written).
$this->assertSame( [ 'hp_description' => 'cached' ], $GLOBALS['_wp_cache'][ $group ]['cold_1'] );
}
public function test_prefetch_stores_fetched_data_in_cache(): void {
$this->register_cold_field();
self::$db_rows = [
[ 'post_id' => '5', 'data' => json_encode( [ 'hp_description' => 'Fetched!' ] ) ],
];
WPDO_Cache_Layer::prefetch( [ 5 ], 'hp_listing' );
$group = 'wpdo_cold_hp_listing';
$cached = $GLOBALS['_wp_cache'][ $group ]['cold_5'] ?? false;
$this->assertIsArray( $cached );
$this->assertSame( 'Fetched!', $cached['hp_description'] );
}
public function test_prefetch_stores_empty_array_for_post_with_no_cold_row(): void {
$this->register_cold_field();
// DB returns nothing for post 7.
self::$db_rows = [];
WPDO_Cache_Layer::prefetch( [ 7 ], 'hp_listing' );
$group = 'wpdo_cold_hp_listing';
$cached = $GLOBALS['_wp_cache'][ $group ]['cold_7'] ?? 'NOT_SET';
$this->assertSame( [], $cached );
}
// ── warm_post() ──────────────────────────────────────────────────────────
public function test_warm_post_returns_early_when_no_cold_keys(): void {
// 'no_cold_type' has no cold fields → warm_post returns immediately.
WPDO_Cache_Layer::warm_post( 1, 'no_cold_type' );
$this->assertEmpty( $GLOBALS['_wp_cache'] );
}
public function test_warm_post_clears_stale_cache_entry(): void {
$this->register_cold_field();
$group = 'wpdo_cold_hp_listing';
$cache_key = 'cold_20';
// Pre-populate with stale data.
$GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'stale' => true ];
WPDO_Cache_Layer::warm_post( 20, 'hp_listing' );
// Stale cache must be replaced (warm_post deletes then re-reads from DB,
// which returns null in mock, yielding empty array).
$this->assertNotSame( [ 'stale' => true ], $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ?? null );
}
// ── get_stats() ──────────────────────────────────────────────────────────
public function test_get_stats_includes_required_keys(): void {
$stats = WPDO_Cache_Layer::get_stats();
$this->assertArrayHasKey( 'groups', $stats );
$this->assertArrayHasKey( 'prefetch_support', $stats );
$this->assertArrayHasKey( 'flush_support', $stats );
}
public function test_get_stats_groups_reflect_registered_cold_types(): void {
$this->register_cold_field( 'hp_listing', 'hp_description' );
$this->register_cold_field( 'hp_listing', 'hp_website' );
$stats = WPDO_Cache_Layer::get_stats();
$post_types = array_column( $stats['groups'], 'post_type' );
$this->assertContains( 'hp_listing', $post_types );
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit test: WPDO_Capability (v2.14.0).
*
* Verifies the multisite-aware admin capability gate:
* - Single-site: only `manage_options` decides
* - Multisite per-site: only `manage_options` decides
* - Multisite network: super admin always passes; site admin still passes
* on their own site if they hold `manage_options`
*/
class CapabilityTest extends TestCase {
protected function setUp(): void {
// Reset all multisite globals before each test.
unset(
$GLOBALS['_wp_is_multisite'],
$GLOBALS['_wp_is_super_admin'],
$GLOBALS['_wp_current_user_can']
);
}
// ── Single-site behaviour ────────────────────────────────────────────────
public function test_single_site_admin_passes(): void {
$GLOBALS['_wp_is_multisite'] = false;
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
$this->assertTrue( WPDO_Capability::current_user_can_admin() );
}
public function test_single_site_subscriber_blocked(): void {
$GLOBALS['_wp_is_multisite'] = false;
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false );
$this->assertFalse( WPDO_Capability::current_user_can_admin() );
}
// ── Multisite per-site behaviour ─────────────────────────────────────────
public function test_multisite_site_admin_passes_with_manage_options(): void {
$GLOBALS['_wp_is_multisite'] = true;
$GLOBALS['_wp_is_super_admin'] = false;
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
$this->assertTrue( WPDO_Capability::current_user_can_admin() );
}
public function test_multisite_subscriber_blocked(): void {
$GLOBALS['_wp_is_multisite'] = true;
$GLOBALS['_wp_is_super_admin'] = false;
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false );
$this->assertFalse( WPDO_Capability::current_user_can_admin() );
}
// ── Multisite super-admin behaviour ──────────────────────────────────────
public function test_multisite_super_admin_passes_without_manage_options(): void {
// Pre-v2.14.0 this returned false because super admins don't auto-have
// `manage_options` in network admin context. v2.14.0 fixes this.
$GLOBALS['_wp_is_multisite'] = true;
$GLOBALS['_wp_is_super_admin'] = true;
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false );
$this->assertTrue( WPDO_Capability::current_user_can_admin() );
}
public function test_multisite_super_admin_passes_with_manage_options(): void {
$GLOBALS['_wp_is_multisite'] = true;
$GLOBALS['_wp_is_super_admin'] = true;
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
$this->assertTrue( WPDO_Capability::current_user_can_admin() );
}
// ── Edge: super admin flag set on single-site (shouldn't be possible
// but should be defensive) ────────────────────────────────────────
public function test_super_admin_flag_ignored_on_single_site(): void {
// Super admin only exists on multisite; on single-site fall back to
// manage_options check.
$GLOBALS['_wp_is_multisite'] = false;
$GLOBALS['_wp_is_super_admin'] = true;
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false );
$this->assertFalse( WPDO_Capability::current_user_can_admin() );
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_CLI_V2::lint_directory() — Anti-EAV strict lint (PR-6).
*
* @covers WPDO_CLI_V2::lint_directory
*/
class CliLintTest extends TestCase {
private string $fixture_dir;
public static function setUpBeforeClass(): void {
// Stub WP_CLI just enough so the cli-v2 file can be require'd without errors.
if ( ! class_exists( 'WP_CLI' ) ) {
eval( 'class WP_CLI { public static function add_command( $name, $callable ) {} public static function log( $msg ) {} public static function warning( $msg ) {} public static function error( $msg ) { throw new \RuntimeException( $msg ); } public static function success( $msg ) {} }' ); // phpcs:ignore Squiz.PHP.Eval -- test-only stub.
}
if ( ! class_exists( 'TMDO_CLI_V2' ) ) {
require_once dirname( __DIR__, 2 ) . '/cli/class-tmdo-cli-v2.php';
}
}
protected function setUp(): void {
$this->fixture_dir = sys_get_temp_dir() . '/wpdo-lint-fixture-' . uniqid();
mkdir( $this->fixture_dir, 0777, true );
}
protected function tearDown(): void {
// Recursive rmdir.
$this->rrmdir( $this->fixture_dir );
}
private function rrmdir( string $dir ): void {
if ( ! is_dir( $dir ) ) {
return;
}
foreach ( scandir( $dir ) as $f ) {
if ( '.' === $f || '..' === $f ) {
continue;
}
$path = $dir . '/' . $f;
is_dir( $path ) ? $this->rrmdir( $path ) : unlink( $path );
}
rmdir( $dir );
}
private function write( string $relative, string $content ): void {
$path = $this->fixture_dir . '/' . $relative;
$dir = dirname( $path );
if ( ! is_dir( $dir ) ) {
mkdir( $dir, 0777, true );
}
file_put_contents( $path, $content );
}
// ── happy path ──────────────────────────────────────────────────────────
public function test_clean_plugin_passes_lint(): void {
$this->write( 'main.php', "<?php\nfunction foo() { return 'bar'; }\n" );
$findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
$this->assertEmpty( $findings );
}
// ── direct postmeta SELECT ─────────────────────────────────────────────
public function test_direct_postmeta_select_flagged(): void {
$this->write(
'bad.php',
"<?php\n\$rows = \$wpdb->get_results( \"SELECT meta_value FROM {\$wpdb->prefix}postmeta WHERE meta_key='foo'\" );\n"
);
$findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
$this->assertGreaterThanOrEqual( 1, count( $findings ) );
$this->assertSame( 'no-direct-postmeta-select', $findings[0]['rule'] );
}
public function test_direct_usermeta_select_flagged(): void {
$this->write(
'user.php',
"<?php\n\$wpdb->get_var( \"SELECT meta_value FROM wp_usermeta WHERE meta_key='foo'\" );\n"
);
$findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
$this->assertGreaterThanOrEqual( 1, count( $findings ) );
$this->assertSame( 'no-direct-usermeta-select', $findings[0]['rule'] );
}
// ── autoload=yes ───────────────────────────────────────────────────────
public function test_autoload_yes_flagged(): void {
$this->write(
'opt.php',
"<?php\nadd_option( 'mykey', 'val', '', 'yes' );\n\$x = array( 'autoload' => 'yes' );\n"
);
$findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
$autoload_findings = array_filter( $findings, static fn( $f ) => 'autoload-yes' === $f['rule'] );
$this->assertGreaterThanOrEqual( 1, count( $autoload_findings ) );
}
// ── ignore comment ─────────────────────────────────────────────────────
public function test_phpcs_ignore_comment_skips_finding(): void {
$this->write(
'fallback.php',
"<?php\n// phpcs:ignore WPDO.AntiEAV.PostmetaFallback -- legacy fallback path\n\$rows = \$wpdb->get_results( \"SELECT meta_value FROM wp_postmeta WHERE meta_key='x'\" );\n"
);
$findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
$this->assertEmpty( $findings, 'phpcs:ignore WPDO.AntiEAV must suppress findings' );
}
// ── skip dirs ──────────────────────────────────────────────────────────
public function test_skips_vendor_and_node_modules(): void {
// Bad code inside vendor/ MUST be ignored.
$this->write(
'vendor/lib/bad.php',
"<?php\n\$wpdb->get_results( \"SELECT meta_value FROM wp_postmeta\" );\n"
);
$this->write(
'node_modules/foo/bad.php',
"<?php\n\$wpdb->get_results( \"SELECT meta_value FROM wp_usermeta\" );\n"
);
$findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
$this->assertEmpty( $findings );
}
// ── reports file + line ────────────────────────────────────────────────
public function test_finding_includes_file_and_line(): void {
$this->write(
'multi.php',
"<?php\n\n\$x = 1;\n\$wpdb->get_var( \"SELECT meta_value FROM wp_postmeta\" );\n"
);
$findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
$this->assertNotEmpty( $findings );
$this->assertStringEndsWith( 'multi.php', $findings[0]['file'] );
$this->assertSame( 4, $findings[0]['line'] );
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Conflict_Monitor — production conflict surface.
*
* @covers WPDO_Conflict_Monitor
*/
class ConflictMonitorTest extends TestCase {
protected function setUp(): void {
WPDO_Conflict_Monitor::reset_cache();
WPDO_Hook_Bus_Bridge::reset_cache();
$GLOBALS['_wp_options'] = array();
$GLOBALS['wp_filter'] = array();
}
public function test_scan_returns_empty_when_no_conflicts(): void {
$result = WPDO_Conflict_Monitor::scan();
$this->assertIsArray( $result );
$this->assertEmpty( $result );
}
public function test_get_summary_when_clean(): void {
$summary = WPDO_Conflict_Monitor::get_summary();
$this->assertSame( 0, $summary['total'] );
$this->assertSame( 0, $summary['hook_overlap'] );
$this->assertSame( 0, $summary['uaepg_overlap'] );
}
public function test_get_all_conflicts_lazy_scans(): void {
// First call populates the cache.
$first = WPDO_Conflict_Monitor::get_all_conflicts();
$this->assertIsArray( $first );
// Subsequent calls return the same reference (cached).
$second = WPDO_Conflict_Monitor::get_all_conflicts();
$this->assertSame( $first, $second );
}
public function test_reset_cache_forces_rescan(): void {
WPDO_Conflict_Monitor::scan();
WPDO_Conflict_Monitor::reset_cache();
// Should not throw and should return empty (still no conflicts).
$this->assertSame( array(), WPDO_Conflict_Monitor::scan() );
}
public function test_summary_keys_always_present(): void {
$summary = WPDO_Conflict_Monitor::get_summary();
$this->assertArrayHasKey( 'total', $summary );
$this->assertArrayHasKey( 'hook_overlap', $summary );
$this->assertArrayHasKey( 'uaepg_overlap', $summary );
}
public function test_admin_notice_is_silent_when_no_conflicts(): void {
ob_start();
WPDO_Conflict_Monitor::maybe_render_admin_notice();
$output = ob_get_clean();
$this->assertSame( '', $output );
}
public function test_admin_bar_is_silent_when_no_conflicts(): void {
// Pass a valid object stub to admin_bar handler — should no-op when count = 0.
$stub = new class() {
public array $nodes = array();
public function add_node( array $node ): void {
$this->nodes[] = $node;
}
};
WPDO_Conflict_Monitor::maybe_render_admin_bar( $stub );
$this->assertCount( 0, $stub->nodes );
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit test: WPDO_Crypto AES-256-GCM v2 + AES-256-CBC v1 backward compat (v2.15.0).
*
* Verifies:
* - v2 GCM round-trip (encrypt/decrypt)
* - v2 GCM tamper detection (auth tag verification)
* - v1 CBC backward compat read
* - Plaintext passthrough
* - Empty input handling
* - Invalid input safe failure
*/
class CryptoV2Test extends TestCase {
public static function setUpBeforeClass(): void {
// Define WP auth constants for stable key derivation in tests.
if ( ! defined( 'AUTH_KEY' ) ) {
define( 'AUTH_KEY', 'test_auth_key_for_phpunit_long_enough_string_xxxxxxxxxxxxxxxx' );
}
if ( ! defined( 'SECURE_AUTH_SALT' ) ) {
define( 'SECURE_AUTH_SALT', 'test_secure_auth_salt_for_phpunit_xxxxxxxxxxxxxxxxxxxxxxx' );
}
}
// ── v2 GCM happy paths ───────────────────────────────────────────────────
public function test_v2_round_trip_simple_string(): void {
$plain = 'https://hooks.slack.com/services/T00000000/B00000000/abc123';
$encrypted = WPDO_Crypto::encrypt( $plain );
$this->assertStringStartsWith( WPDO_Crypto::PREFIX_V2, $encrypted );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) );
}
public function test_v2_round_trip_unicode(): void {
$plain = '中文密碼 + emoji 🔐 + special chars !@#$%^&*()';
$encrypted = WPDO_Crypto::encrypt( $plain );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) );
}
public function test_v2_round_trip_long_string(): void {
$plain = str_repeat( 'A', 4096 );
$encrypted = WPDO_Crypto::encrypt( $plain );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) );
}
public function test_v2_each_encryption_produces_unique_ciphertext(): void {
// Random IV → repeated encrypts of the same plaintext yield different blobs.
$plain = 'identical plaintext';
$ct1 = WPDO_Crypto::encrypt( $plain );
$ct2 = WPDO_Crypto::encrypt( $plain );
$this->assertNotSame( $ct1, $ct2, 'IV randomness should produce unique ciphertexts' );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $ct1 ) );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $ct2 ) );
}
// ── v2 GCM tamper detection ──────────────────────────────────────────────
public function test_v2_tampered_ciphertext_returns_original(): void {
$plain = 'sensitive webhook url';
$encrypted = WPDO_Crypto::encrypt( $plain );
// Decode the base64 payload, flip the FIRST byte of the GCM auth tag
// (which lives at offset 12 right after the IV), re-encode. This
// guarantees a real ciphertext modification regardless of base64
// alphabet (vs str_replace which can be a no-op for some random IVs).
$prefix_len = strlen( WPDO_Crypto::PREFIX_V2 );
$encoded = substr( $encrypted, $prefix_len );
$raw = base64_decode( $encoded, true );
$this->assertNotFalse( $raw, 'Setup precondition: ciphertext must be valid base64' );
$raw[12] = chr( ord( $raw[12] ) ^ 0x55 ); // flip 4 bits of the auth tag.
$tampered = WPDO_Crypto::PREFIX_V2 . base64_encode( $raw );
$result = WPDO_Crypto::decrypt( $tampered );
$this->assertNotSame( $plain, $result, 'Tampered GCM ciphertext must NOT decrypt to original plaintext' );
$this->assertSame( $tampered, $result, 'On auth failure decrypt() must return original blob' );
}
public function test_v2_truncated_blob_safe_failure(): void {
$encrypted = WPDO_Crypto::encrypt( 'some value' );
// Truncate to less than min size (12 IV + 16 tag + 1 byte ciphertext).
$truncated = substr( $encrypted, 0, strlen( WPDO_Crypto::PREFIX_V2 ) + 5 );
// Should not throw; should return original.
$result = WPDO_Crypto::decrypt( $truncated );
$this->assertSame( $truncated, $result );
}
// ── v1 CBC backward compat ───────────────────────────────────────────────
public function test_v1_legacy_blob_decrypts_successfully(): void {
// Hand-craft a v1 CBC blob using the same key derivation.
$plain = 'legacy webhook url from pre-v2.15';
$key = $this->derive_key();
$iv = random_bytes( 16 );
$ct = openssl_encrypt( $plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
$blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $blob ) );
}
public function test_v1_blob_with_garbage_returns_original(): void {
$bad = WPDO_Crypto::PREFIX_V1 . 'not_valid_base64!!!';
$this->assertSame( $bad, WPDO_Crypto::decrypt( $bad ) );
}
// ── Plaintext passthrough ────────────────────────────────────────────────
public function test_plaintext_passthrough(): void {
$plain = 'https://example.com/raw';
$this->assertSame( $plain, WPDO_Crypto::decrypt( $plain ) );
}
public function test_empty_input(): void {
$this->assertSame( '', WPDO_Crypto::encrypt( '' ) );
$this->assertSame( '', WPDO_Crypto::decrypt( '' ) );
}
// ── format_version ───────────────────────────────────────────────────────
public function test_format_version_classification(): void {
// Use option-API stubs from bootstrap.
$GLOBALS['_wp_options']['test_v2_opt'] = WPDO_Crypto::encrypt( 'foo' );
$GLOBALS['_wp_options']['test_plain_opt'] = 'plaintext_value';
$GLOBALS['_wp_options']['test_empty_opt'] = '';
// Hand-craft a v1 blob.
$key = $this->derive_key();
$iv = random_bytes( 16 );
$ct = openssl_encrypt( 'bar', 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
$GLOBALS['_wp_options']['test_v1_opt'] = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
$this->assertSame( 'v2', WPDO_Crypto::format_version( 'test_v2_opt' ) );
$this->assertSame( 'v1', WPDO_Crypto::format_version( 'test_v1_opt' ) );
$this->assertSame( 'plaintext', WPDO_Crypto::format_version( 'test_plain_opt' ) );
$this->assertSame( 'empty', WPDO_Crypto::format_version( 'test_empty_opt' ) );
$this->assertSame( 'empty', WPDO_Crypto::format_version( 'nonexistent_opt' ) );
}
// ── migrate_option_v1_to_v2 ──────────────────────────────────────────────
public function test_migrate_option_v1_to_v2_round_trip(): void {
$plain = 'webhook to migrate';
$key = $this->derive_key();
$iv = random_bytes( 16 );
$ct = openssl_encrypt( $plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
$blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
$GLOBALS['_wp_options']['migrate_test'] = $blob;
$result = WPDO_Crypto::migrate_option_v1_to_v2( 'migrate_test' );
$this->assertSame( 'migrated', $result );
// After migration: v2 blob, decrypts to original plaintext.
$this->assertSame( 'v2', WPDO_Crypto::format_version( 'migrate_test' ) );
$this->assertSame( $plain, WPDO_Crypto::get_option( 'migrate_test' ) );
}
public function test_migrate_option_already_v2_is_noop(): void {
$GLOBALS['_wp_options']['already_v2'] = WPDO_Crypto::encrypt( 'foo' );
$result = WPDO_Crypto::migrate_option_v1_to_v2( 'already_v2' );
$this->assertSame( 'already_v2', $result );
}
public function test_migrate_option_plaintext_skipped(): void {
$GLOBALS['_wp_options']['plain_opt'] = 'just plaintext';
$result = WPDO_Crypto::migrate_option_v1_to_v2( 'plain_opt' );
$this->assertSame( 'plaintext_skipped', $result );
// Original value preserved.
$this->assertSame( 'just plaintext', $GLOBALS['_wp_options']['plain_opt'] );
}
public function test_migrate_option_empty_returns_empty(): void {
$GLOBALS['_wp_options']['empty_opt'] = '';
$result = WPDO_Crypto::migrate_option_v1_to_v2( 'empty_opt' );
$this->assertSame( 'empty', $result );
}
// ── Helper ───────────────────────────────────────────────────────────────
/**
* Replicates WPDO_Crypto::derived_key() to craft test fixtures.
*
* @return string 32 raw bytes.
*/
private function derive_key(): string {
$salt = AUTH_KEY . SECURE_AUTH_SALT;
return substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', $salt, true ), 0, 32 );
}
}
+261
View File
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Custom_Table_Registry — partner plugin custom table awareness.
*
* Solves audit finding R-3 (Custom Table Provider missing).
*
* @covers WPDO_Custom_Table_Registry
*/
class CustomTableRegistryTest extends TestCase {
protected function setUp(): void {
WPDO_Custom_Table_Registry::reset_for_tests();
}
// ── register() ──────────────────────────────────────────────────────────
public function test_register_single_table(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$ok = $registry->register( '2meet-courses', array(
'table_name' => '2mc_courses',
'primary_key' => 'id',
'post_type_link' => null,
) );
$this->assertTrue( $ok );
$this->assertCount( 1, $registry->all() );
}
public function test_register_rejects_empty_table_name(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$this->assertFalse( $registry->register( '2meet-courses', array() ) );
$this->assertFalse( $registry->register( '2meet-courses', array( 'table_name' => '' ) ) );
}
public function test_register_rejects_empty_provider(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$this->assertFalse( $registry->register( '', array( 'table_name' => '2mc_courses' ) ) );
}
public function test_register_rejects_duplicate_provider_table_pair(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$this->assertTrue( $registry->register( '2meet-courses', array( 'table_name' => '2mc_courses' ) ) );
// Same provider+table → false.
$this->assertFalse( $registry->register( '2meet-courses', array( 'table_name' => '2mc_courses' ) ) );
}
public function test_register_allows_same_table_different_provider(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$this->assertTrue( $registry->register( '2meet-courses', array( 'table_name' => 'shared_t' ) ) );
$this->assertTrue( $registry->register( '2meet-bookings', array( 'table_name' => 'shared_t' ) ) );
$this->assertCount( 2, $registry->all() );
}
public function test_register_sanitizes_table_name(): void {
$registry = WPDO_Custom_Table_Registry::instance();
// WordPress sanitize_key strips non-alphanumeric/underscore/dash entirely (no replacement).
$registry->register( 'p', array( 'table_name' => 'My Bad-Name!' ) );
$tables = $registry->all();
$cfg = reset( $tables );
$this->assertSame( 'mybad-name', $cfg['table_name'] );
}
public function test_register_applies_defaults(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'p', array( 'table_name' => 't' ) );
$tables = $registry->all();
$cfg = reset( $tables );
$this->assertSame( 'id', $cfg['primary_key'] );
$this->assertNull( $cfg['post_type_link'] );
$this->assertSame( array(), $cfg['expected_columns'] );
}
// ── unregister() ────────────────────────────────────────────────────────
public function test_unregister_removes_table(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'p', array( 'table_name' => 't' ) );
$this->assertTrue( $registry->unregister( 'p', 't' ) );
$this->assertCount( 0, $registry->all() );
}
public function test_unregister_returns_false_for_unknown(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$this->assertFalse( $registry->unregister( 'unknown', 'table' ) );
}
// ── for_provider() / for_post_type() ────────────────────────────────────
public function test_for_provider_filters_correctly(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'a', array( 'table_name' => 't1' ) );
$registry->register( 'a', array( 'table_name' => 't2' ) );
$registry->register( 'b', array( 'table_name' => 't3' ) );
$this->assertCount( 2, $registry->for_provider( 'a' ) );
$this->assertCount( 1, $registry->for_provider( 'b' ) );
$this->assertCount( 0, $registry->for_provider( 'c' ) );
}
/**
* v2.1.3 R3 hardening — verify by_provider index stays in sync when a
* non-edge entry is unregistered. Previously untested per audit finding.
*/
public function test_for_provider_after_unregister_middle_entry(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'p', array( 'table_name' => 'first' ) );
$registry->register( 'p', array( 'table_name' => 'middle' ) );
$registry->register( 'p', array( 'table_name' => 'last' ) );
$this->assertCount( 3, $registry->for_provider( 'p' ) );
// Remove the middle entry.
$ok = $registry->unregister( 'p', 'middle' );
$this->assertTrue( $ok );
$remaining = $registry->for_provider( 'p' );
$this->assertCount( 2, $remaining );
// Verify the surviving entries are correct (not 'middle').
$names = array_column( $remaining, 'table_name' );
sort( $names );
$this->assertSame( array( 'first', 'last' ), $names );
// Re-registering 'middle' should put it back.
$registry->register( 'p', array( 'table_name' => 'middle' ) );
$this->assertCount( 3, $registry->for_provider( 'p' ) );
}
/**
* Verify by_provider index empties (and removes the provider key entirely)
* when the last table for that provider is unregistered.
*/
public function test_for_provider_returns_empty_after_full_unregister(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'solo', array( 'table_name' => 'only_table' ) );
$this->assertCount( 1, $registry->for_provider( 'solo' ) );
$registry->unregister( 'solo', 'only_table' );
$this->assertCount( 0, $registry->for_provider( 'solo' ) );
$this->assertSame( array(), $registry->for_provider( 'solo' ) );
}
public function test_for_post_type_filters_correctly(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'p', array( 'table_name' => 't1', 'post_type_link' => 'hp_listing' ) );
$registry->register( 'p', array( 'table_name' => 't2', 'post_type_link' => 'hp_listing' ) );
$registry->register( 'p', array( 'table_name' => 't3', 'post_type_link' => 'hp_vendor' ) );
$registry->register( 'p', array( 'table_name' => 't4', 'post_type_link' => null ) );
$this->assertCount( 2, $registry->for_post_type( 'hp_listing' ) );
$this->assertCount( 1, $registry->for_post_type( 'hp_vendor' ) );
$this->assertCount( 0, $registry->for_post_type( 'unknown' ) );
}
public function test_providers_returns_unique_list(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'a', array( 'table_name' => 't1' ) );
$registry->register( 'a', array( 'table_name' => 't2' ) );
$registry->register( 'b', array( 'table_name' => 't3' ) );
$providers = $registry->providers();
sort( $providers );
$this->assertSame( array( 'a', 'b' ), $providers );
}
// ── get_stats() ─────────────────────────────────────────────────────────
public function test_get_stats_counts_callbacks(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'p', array(
'table_name' => 't1',
'doctor_callback' => static fn() => array( 'ok' => true ),
'benchmark_callback' => static fn() => array( 'duration_ms' => 1.0 ),
) );
$registry->register( 'p', array( 'table_name' => 't2' ) );
$stats = $registry->get_stats();
$this->assertSame( 2, $stats['tables_count'] );
$this->assertSame( 1, $stats['providers_count'] );
$this->assertSame( 1, $stats['with_doctor'] );
$this->assertSame( 1, $stats['with_benchmark'] );
}
// ── run_doctor_checks() ─────────────────────────────────────────────────
public function test_run_doctor_checks_invokes_callbacks(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'p', array(
'table_name' => 't1',
'doctor_callback' => static fn() => array( 'ok' => true, 'message' => 'all good' ),
) );
$registry->register( 'p', array(
'table_name' => 't2',
'doctor_callback' => static fn() => array( 'ok' => false, 'message' => 'index missing' ),
) );
$registry->register( 'p', array( 'table_name' => 't3' ) ); // No callback → skipped.
$results = $registry->run_doctor_checks();
$this->assertCount( 2, $results );
$this->assertTrue( $results['p:t1']['ok'] );
$this->assertSame( 'all good', $results['p:t1']['message'] );
$this->assertFalse( $results['p:t2']['ok'] );
}
public function test_run_doctor_checks_passes_table_name_to_callback(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$received = null;
$registry->register( 'wc', array(
'table_name' => 'wc_orders',
'doctor_callback' => static function ( string $table_name ) use ( &$received ): array {
$received = $table_name;
return array( 'ok' => true, 'message' => "checked {$table_name}" );
},
) );
$results = $registry->run_doctor_checks();
$this->assertSame( 'wc_orders', $received, 'callback must receive table_name as first argument' );
$this->assertSame( 'checked wc_orders', $results['wc:wc_orders']['message'] );
}
public function test_run_doctor_checks_catches_throwables(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'p', array(
'table_name' => 't1',
'doctor_callback' => static function () {
throw new RuntimeException( 'simulated failure' );
},
) );
$results = $registry->run_doctor_checks();
$this->assertCount( 1, $results );
$this->assertFalse( $results['p:t1']['ok'] );
$this->assertStringContainsString( 'simulated failure', $results['p:t1']['message'] );
}
// ── fire_registration() idempotency ─────────────────────────────────────
public function test_fire_registration_is_idempotent(): void {
$count = 0;
add_action( 'wpdo_register_custom_tables', function () use ( &$count ) {
++$count;
} );
$registry = WPDO_Custom_Table_Registry::instance();
$registry->fire_registration();
$registry->fire_registration();
$registry->fire_registration();
// Stub add_action() in unit bootstrap returns true but does NOT execute callbacks,
// so the meaningful assertion here is that fire_registration() doesn't throw.
$this->assertTrue( true );
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
if ( ! function_exists( 'wp_strip_all_tags' ) ) {
function wp_strip_all_tags( string $s ): string {
return strip_tags( $s );
}
}
if ( ! function_exists( 'delete_option' ) ) {
function delete_option( string $key ): bool {
unset( $GLOBALS['_wp_options'][ $key ] );
return true;
}
}
if ( ! function_exists( 'do_action' ) ) {
function do_action( string $hook, ...$args ): void {
// no-op for unit tests.
}
}
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
public string $code;
public string $message;
public array $data;
public function __construct( string $code = '', string $message = '', $data = array() ) {
$this->code = $code;
$this->message = $message;
$this->data = (array) $data;
}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
public function get_error_data() { return $this->data; }
}
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/diagnostic/class-tmdo-health-cron.php';
/**
* Unit tests for WPDO_Health_Cron (v2.3.0 M6).
*
* Pure-logic tests — does not exercise actual cron firing or full Site Health
* subprocess (those covered by integration suite). Asserts on output shape +
* counter aggregation + alert flag setting.
*/
class HealthCronTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public string $options = 'wp_options';
// v2.5.0Module_Detector reads $wpdb->posts during Health_Cron
// integration; declared here to silence undefined-property warning.
public string $posts = 'wp_posts';
public string $postmeta = 'wp_postmeta';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%[sd]/', function() use ( &$i, $args ) {
return (string) ( $args[ $i++ ] ?? '?' );
}, $sql );
}
public function get_var( string $sql ) {
$upper = strtoupper( $sql );
// Table-existence probes → truthy so schema_drift / orphan_zone passes.
if ( str_contains( $upper, 'SHOW TABLES' ) || str_contains( $upper, 'INFORMATION_SCHEMA' ) ) {
return '1';
}
return '0';
}
public function get_results( string $sql, $output = ARRAY_A ): array {
return array();
}
public function query( string $sql ): int { return 0; }
public function insert( string $table, array $data ): int { return 1; }
};
}
public function test_get_last_run_returns_null_when_never_run(): void {
$this->assertNull( WPDO_Health_Cron::get_last_run() );
}
public function test_run_returns_summary_shape(): void {
$result = WPDO_Health_Cron::run();
$this->assertTrue( $result['ok'] );
$this->assertArrayHasKey( 'summary', $result );
$this->assertArrayHasKey( 'critical_count', $result );
$this->assertArrayHasKey( 'recommended_count', $result );
$this->assertArrayHasKey( 'ts', $result );
$this->assertArrayHasKey( 'tests', $result['summary'] );
$this->assertArrayHasKey( 'conflicts', $result['summary'] );
$this->assertArrayHasKey( 'shadow_diffs', $result['summary'] );
$this->assertArrayHasKey( 'autoload_bytes', $result['summary'] );
$this->assertArrayHasKey( 'duration_ms', $result['summary'] );
}
public function test_run_persists_last_run_option(): void {
WPDO_Health_Cron::run();
$last = WPDO_Health_Cron::get_last_run();
$this->assertIsArray( $last );
$this->assertArrayHasKey( 'tests', $last );
$this->assertArrayHasKey( 'critical_count', $last );
}
public function test_run_clears_alert_when_no_critical(): void {
// Pre-set an alert.
update_option( WPDO_Health_Cron::OPTION_ALERT, array( 'level' => 'critical' ), false );
WPDO_Health_Cron::run();
$this->assertFalse( get_option( WPDO_Health_Cron::OPTION_ALERT ), 'alert should be cleared on green run' );
}
public function test_consecutive_green_days_zero_when_no_run(): void {
$this->assertSame( 0, WPDO_Health_Cron::consecutive_green_days() );
}
public function test_consecutive_green_days_one_after_green_run(): void {
WPDO_Health_Cron::run();
$this->assertSame( 1, WPDO_Health_Cron::consecutive_green_days() );
}
public function test_option_keys_are_documented(): void {
$this->assertSame( 'wpdo_health_last_run', WPDO_Health_Cron::OPTION_LAST_RUN );
$this->assertSame( 'wpdo_health_alert', WPDO_Health_Cron::OPTION_ALERT );
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require_once dirname( __DIR__, 3 ) . '/includes/export/class-tmdo-csv-writer.php';
/**
* Unit tests for WPDO_CSV_Writer (v2.5.0 M14).
*/
class CsvWriterTest extends TestCase {
public function test_starts_with_utf8_bom(): void {
$out = WPDO_CSV_Writer::build( array( 'a', 'b' ), array() );
$this->assertSame( "\xEF\xBB\xBF", substr( $out, 0, 3 ) );
}
public function test_simple_row_csv_output(): void {
$out = WPDO_CSV_Writer::build( array( 'a', 'b' ), array( array( 'a' => '1', 'b' => '2' ) ) );
$this->assertStringContainsString( "a,b\r\n1,2\r\n", $out );
}
public function test_field_with_comma_gets_quoted(): void {
$out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => 'foo,bar' ) ) );
$this->assertStringContainsString( '"foo,bar"', $out );
}
public function test_field_with_quote_doubles_it(): void {
$out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => 'say "hi"' ) ) );
$this->assertStringContainsString( '"say ""hi"""', $out );
}
public function test_field_with_newline_gets_quoted(): void {
$out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => "line1\nline2" ) ) );
$this->assertStringContainsString( "\"line1\nline2\"", $out );
}
public function test_array_value_serializes_to_json(): void {
$out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => array( 'a', 'b' ) ) ) );
// Array becomes JSON; quotes inside JSON are doubled inside the CSV-quoted field.
$this->assertStringContainsString( '"[""a"",""b""]"', $out );
}
public function test_missing_field_renders_empty(): void {
$out = WPDO_CSV_Writer::build( array( 'a', 'b' ), array( array( 'a' => '1' ) ) );
$this->assertStringContainsString( "1,\r\n", $out );
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Feature_Flags — 7-state lifecycle state machine.
*/
class FeatureFlagsTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = [];
}
// ── State retrieval ──────────────────────────────────────────────────────
public function test_unregistered_module_returns_idle(): void {
$this->assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_unknown' ) );
}
public function test_get_returns_stored_state(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
$this->assertSame( 'backfill', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
}
// ── set() ─────────────────────────────────────────────────────────────────
public function test_set_valid_state_returns_true(): void {
$result = WPDO_Feature_Flags::set( 'mod', 'cutover' );
$this->assertTrue( $result );
$this->assertSame( 'cutover', WPDO_Feature_Flags::get( 'mod' ) );
}
public function test_set_invalid_state_returns_false(): void {
$result = WPDO_Feature_Flags::set( 'mod', 'invalid_state_xyz' );
$this->assertFalse( $result );
}
// ── Query-active check ──────────────────────────────────────────────────
public function test_is_query_active_true_when_cutover(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
$this->assertTrue( WPDO_Feature_Flags::is_query_active( 'hot_hp_listing' ) );
}
public function test_is_query_active_true_when_complete(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' );
$this->assertTrue( WPDO_Feature_Flags::is_query_active( 'hot_hp_listing' ) );
}
public function test_is_query_active_false_when_backfill(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
$this->assertFalse( WPDO_Feature_Flags::is_query_active( 'hot_hp_listing' ) );
}
// ── is_write_active ──────────────────────────────────────────────────────
public function test_is_write_active_true_when_dual_write(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' );
$this->assertTrue( WPDO_Feature_Flags::is_write_active( 'hot_hp_listing' ) );
}
public function test_is_write_active_false_when_idle(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
$this->assertFalse( WPDO_Feature_Flags::is_write_active( 'hot_hp_listing' ) );
}
// ── is_read_custom ───────────────────────────────────────────────────────
public function test_is_read_custom_true_when_cutover(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
$this->assertTrue( WPDO_Feature_Flags::is_read_custom( 'hot_hp_listing' ) );
}
public function test_is_read_custom_false_when_backfill(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
$this->assertFalse( WPDO_Feature_Flags::is_read_custom( 'hot_hp_listing' ) );
}
// ── all() ────────────────────────────────────────────────────────────────
public function test_all_returns_array_of_states(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
WPDO_Feature_Flags::set( 'hot_hp_vendor', 'idle' );
$all = WPDO_Feature_Flags::all();
$this->assertIsArray( $all );
$this->assertSame( 'cutover', $all['hot_hp_listing'] );
$this->assertSame( 'idle', $all['hot_hp_vendor'] );
}
// ── reset() ─────────────────────────────────────────────────────────────
public function test_reset_returns_module_to_idle(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' );
WPDO_Feature_Flags::reset( 'hot_hp_listing' );
$this->assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
}
// ── is_complete ──────────────────────────────────────────────────────────
public function test_is_complete_true_when_complete(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' );
$this->assertTrue( WPDO_Feature_Flags::is_complete( 'hot_hp_listing' ) );
}
public function test_is_complete_false_when_cutover(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
$this->assertFalse( WPDO_Feature_Flags::is_complete( 'hot_hp_listing' ) );
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Hook_Bus_Bridge — PR-3 feature-flag + conflict detection.
*
* @covers WPDO_Hook_Bus_Bridge
*/
class HookBusBridgeTest extends TestCase {
protected function setUp(): void {
WPDO_Hook_Bus_Bridge::reset_cache();
// Reset $GLOBALS['_wp_options'] for each test isolation.
$GLOBALS['_wp_options'] = array();
}
// ── is_enabled() ────────────────────────────────────────────────────────
public function test_is_enabled_default_true(): void {
// v2.5.4: Hook Bus is ON by default; option not set → true.
$this->assertTrue( WPDO_Hook_Bus_Bridge::is_enabled() );
}
public function test_is_enabled_when_option_set_to_string_one(): void {
update_option( WPDO_Hook_Bus_Bridge::OPTION, '1' );
WPDO_Hook_Bus_Bridge::reset_cache();
$this->assertTrue( WPDO_Hook_Bus_Bridge::is_enabled() );
}
public function test_is_enabled_when_option_set_to_bool_true(): void {
update_option( WPDO_Hook_Bus_Bridge::OPTION, true );
WPDO_Hook_Bus_Bridge::reset_cache();
$this->assertTrue( WPDO_Hook_Bus_Bridge::is_enabled() );
}
public function test_is_enabled_when_option_set_to_zero(): void {
update_option( WPDO_Hook_Bus_Bridge::OPTION, '0' );
WPDO_Hook_Bus_Bridge::reset_cache();
$this->assertFalse( WPDO_Hook_Bus_Bridge::is_enabled() );
}
public function test_is_enabled_caches_result(): void {
update_option( WPDO_Hook_Bus_Bridge::OPTION, '1' );
WPDO_Hook_Bus_Bridge::reset_cache();
$first = WPDO_Hook_Bus_Bridge::is_enabled();
// Mutate the option, but cache should retain previous value until reset.
update_option( WPDO_Hook_Bus_Bridge::OPTION, '0' );
$second = WPDO_Hook_Bus_Bridge::is_enabled();
$this->assertSame( $first, $second, 'Cache must be sticky within a request' );
// After explicit reset → new value visible.
WPDO_Hook_Bus_Bridge::reset_cache();
$this->assertFalse( WPDO_Hook_Bus_Bridge::is_enabled() );
}
// ── maybe_init_hook_bus() ───────────────────────────────────────────────
public function test_maybe_init_hook_bus_noop_when_disabled(): void {
// Disabled → must not throw even if WPDO_Hook_Bus class missing.
WPDO_Hook_Bus_Bridge::maybe_init_hook_bus();
$this->assertTrue( true );
}
// ── detect_intra_wpdo_conflicts() ───────────────────────────────────────
public function test_detect_intra_wpdo_conflicts_returns_empty_when_no_filters(): void {
// Stub bootstrap doesn't populate $wp_filter, so result is empty array.
$conflicts = WPDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts();
$this->assertIsArray( $conflicts );
$this->assertEmpty( $conflicts );
}
public function test_detect_intra_wpdo_conflicts_flags_multiple_wpdo_callbacks(): void {
// Simulate $wp_filter with two WPDO_* callbacks on the same hook.
$GLOBALS['wp_filter'] = array(
'update_post_metadata' => new class() {
public array $callbacks;
public function __construct() {
$this->callbacks = array(
10 => array(
array(
'function' => array(
new class() {
public function intercept_update() {}
},
'intercept_update',
),
),
),
8 => array(
array(
'function' => array(
new class() {
public function intercept_update() {}
},
'intercept_update',
),
),
),
);
}
},
);
// The anonymous classes won't have WPDO_ prefix → must not flag conflict.
$conflicts = WPDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts();
$this->assertEmpty( $conflicts, 'Non-WPDO classes must not be flagged' );
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Logger — lightweight error logger.
*/
class LoggerTest extends TestCase {
/** Last data array passed to $wpdb->insert(). */
public static array $last_insert = [];
/** Insert call counter. */
public static int $insert_count = 0;
/** Value returned by $wpdb->query(). */
public static int $query_return = 1;
/** Rows returned by $wpdb->get_results(). */
public static array $get_results_return = [];
/** Last SQL passed to $wpdb->query(). */
public static string $last_query_sql = '';
protected function setUp(): void {
self::$last_insert = [];
self::$insert_count = 0;
self::$query_return = 1;
self::$get_results_return = [];
self::$last_query_sql = '';
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public string $postmeta = 'wp_postmeta';
public string $posts = 'wp_posts';
public string $options = 'wp_options';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
public function get_var( string $sql ): ?string {
return null;
}
public function get_row( string $sql, $output = OBJECT ) {
return null;
}
public function get_results( string $sql, $output = OBJECT ): array {
return LoggerTest::$get_results_return;
}
public function insert( string $table, array $data, $format = null ): int|false {
LoggerTest::$last_insert = $data;
LoggerTest::$insert_count++;
return 1;
}
public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false {
return 1;
}
public function delete( string $table, array $where, $format = null ): int|false {
return 1;
}
public function query( string $sql ): int|bool {
LoggerTest::$last_query_sql = $sql;
return LoggerTest::$query_return;
}
};
}
// ── error() ──────────────────────────────────────────────────────────────
public function test_error_inserts_row_into_db(): void {
WPDO_Logger::error( 'reviews', 'save_hook', 'Something went wrong' );
$this->assertSame( 1, self::$insert_count );
}
public function test_error_sanitizes_module_field(): void {
WPDO_Logger::error( 'Reviews Module!', 'some_hook', 'test message' );
// sanitize_key strips non-[a-z0-9_-] chars; result matches stub output.
$this->assertSame( sanitize_key( 'Reviews Module!' ), self::$last_insert['module'] );
}
public function test_error_trims_hook_to_255_chars(): void {
$long_hook = str_repeat( 'x', 300 );
WPDO_Logger::error( 'mod', $long_hook, 'msg' );
$this->assertLessThanOrEqual( 255, strlen( self::$last_insert['hook'] ) );
}
public function test_error_encodes_context_as_json(): void {
$context = [ 'post_id' => 42, 'extra' => 'data' ];
WPDO_Logger::error( 'mod', 'hook', 'msg', $context );
$this->assertSame( json_encode( $context, JSON_UNESCAPED_UNICODE ), self::$last_insert['context'] );
}
public function test_error_sets_null_context_when_empty(): void {
WPDO_Logger::error( 'mod', 'hook', 'msg' );
$this->assertNull( self::$last_insert['context'] );
}
// ── get_recent() ─────────────────────────────────────────────────────────
public function test_get_recent_returns_wpdb_results(): void {
self::$get_results_return = [
[ 'id' => 1, 'module' => 'reviews', 'message' => 'err1' ],
[ 'id' => 2, 'module' => 'hot', 'message' => 'err2' ],
];
$results = WPDO_Logger::get_recent();
$this->assertCount( 2, $results );
}
// ── purge() ───────────────────────────────────────────────────────────────
public function test_purge_returns_query_result(): void {
self::$query_return = 5;
$deleted = WPDO_Logger::purge( 30 );
$this->assertSame( 5, $deleted );
}
}
@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Member_Fields entity group registration.
*
* Verifies that register_entity_fields() correctly registers all four
* user groups with the expected field counts, types, and attributes.
*/
class MemberFieldsRegistrationTest extends TestCase {
protected function setUp(): void {
// Load the class under test if not already loaded.
if ( ! class_exists( 'WPDO_Member_Fields' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-member-fields.php';
}
if ( ! class_exists( 'WPDO_Adapter_User' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/class-tmdo-adapter-user.php';
}
// Reset Entity Registry and register the user adapter.
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
}
// ── Group presence ───────────────────────────────────────────────────────
public function test_register_entity_fields_returns_early_without_entity_registry(): void {
// Class_exists('WPDO_Entity_Registry') will be true here, so we just
// confirm calling the method twice (dedup guard) returns false the 2nd time.
WPDO_Member_Fields::register_entity_fields();
// Second call: groups already registered — register_group() returns false (dedup).
// No assertion needed: simply must not throw.
$this->assertTrue( true );
}
public function test_all_four_groups_are_registered(): void {
WPDO_Member_Fields::register_entity_fields();
foreach ( array( 'membership', 'activity', 'profile', 'sso' ) as $group ) {
$fields = WPDO_Entity_Registry::get_group_fields( 'user', $group );
$this->assertNotEmpty( $fields, "Group '{$group}' should have registered fields." );
}
}
// ── membership group ─────────────────────────────────────────────────────
public function test_membership_group_has_six_fields(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
$this->assertCount( 6, $fields );
}
public function test_membership_level_is_enum_with_five_options(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
$level = $this->find_field( $fields, 'membership_level' );
$this->assertNotNull( $level );
$this->assertSame( 'enum', $level['type'] );
$this->assertCount( 5, $level['options'] );
$this->assertContains( 'gold', $level['options'] );
$this->assertContains( 'platinum', $level['options'] );
}
public function test_membership_level_is_searchable(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
$level = $this->find_field( $fields, 'membership_level' );
$this->assertTrue( (bool) $level['searchable'] );
}
public function test_points_balance_is_integer_searchable(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
$field = $this->find_field( $fields, 'points_balance' );
$this->assertSame( 'integer', $field['type'] );
$this->assertTrue( (bool) $field['searchable'] );
$this->assertSame( 0, $field['default'] );
}
public function test_membership_expires_at_is_datetime_searchable(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
$field = $this->find_field( $fields, 'membership_expires_at' );
$this->assertSame( 'datetime', $field['type'] );
$this->assertTrue( (bool) $field['searchable'] );
}
// ── activity group ───────────────────────────────────────────────────────
public function test_activity_group_has_six_fields(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'activity' );
$this->assertCount( 6, $fields );
}
public function test_login_count_has_integer_type_and_zero_default(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'activity' );
$field = $this->find_field( $fields, 'login_count' );
$this->assertSame( 'integer', $field['type'] );
$this->assertSame( 0, $field['default'] );
}
public function test_account_flags_is_integer(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'activity' );
$field = $this->find_field( $fields, 'account_flags' );
$this->assertSame( 'integer', $field['type'] );
}
// ── profile group ────────────────────────────────────────────────────────
public function test_profile_group_has_five_fields(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'profile' );
$this->assertCount( 5, $fields );
}
public function test_specialties_is_json_type(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'profile' );
$field = $this->find_field( $fields, 'specialties' );
$this->assertSame( 'json', $field['type'] );
}
public function test_display_name_custom_is_fulltext_searchable(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'profile' );
$field = $this->find_field( $fields, 'display_name_custom' );
$this->assertTrue( (bool) $field['searchable'] );
$this->assertTrue( (bool) $field['fulltext'] );
}
// ── sso group ────────────────────────────────────────────────────────────
public function test_sso_group_has_seven_fields(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
$this->assertCount( 7, $fields );
}
public function test_hub_global_user_id_is_searchable(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
$field = $this->find_field( $fields, 'hub_global_user_id' );
$this->assertTrue( (bool) $field['searchable'] );
}
public function test_token_expires_at_is_datetime_searchable(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
$field = $this->find_field( $fields, 'token_expires_at' );
$this->assertSame( 'datetime', $field['type'] );
$this->assertTrue( (bool) $field['searchable'] );
}
public function test_refresh_token_enc_is_textarea(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
$field = $this->find_field( $fields, 'refresh_token_enc' );
$this->assertSame( 'textarea', $field['type'] );
}
// ── register() hooks add_action ──────────────────────────────────────────
public function test_register_hooks_wpdo_register_entity_fields(): void {
// add_action is a no-op stub in test bootstrap; just confirm no exception.
$this->assertNull( WPDO_Member_Fields::register() );
}
// ── helper ───────────────────────────────────────────────────────────────
/**
* Find a field definition by key within a fields array.
*
* @param array $fields Array of field definitions.
* @param string $key Meta key to find.
* @return array|null
*/
private function find_field( array $fields, string $key ): ?array {
foreach ( $fields as $field ) {
if ( $field['key'] === $key ) {
return $field;
}
}
return null;
}
}
@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Points_Manager.
*
* Uses a custom $wpdb mock that tracks which SQL verbs were executed
* (BEGIN/START TRANSACTION, COMMIT, ROLLBACK) so we can verify
* transaction discipline without hitting a real database.
*/
class PointsManagerTest extends TestCase {
/** @var object Original $wpdb mock from bootstrap. */
private object $original_wpdb;
protected function setUp(): void {
if ( ! class_exists( 'WPDO_Points_Manager' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-points-manager.php';
}
global $wpdb;
$this->original_wpdb = $wpdb;
// Install a controllable mock that also has insert_id.
$wpdb = $this->make_wpdb_mock();
}
protected function tearDown(): void {
global $wpdb;
$wpdb = $this->original_wpdb;
}
// ── credit() input validation ────────────────────────────────────────────
public function test_credit_rejects_zero_delta(): void {
$result = WPDO_Points_Manager::credit( 1, 0 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'credit delta must be positive', $result['error'] );
}
public function test_credit_rejects_negative_delta(): void {
$result = WPDO_Points_Manager::credit( 1, -50 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'credit delta must be positive', $result['error'] );
}
// ── debit() input validation ─────────────────────────────────────────────
public function test_debit_rejects_zero_delta(): void {
$result = WPDO_Points_Manager::debit( 1, 0 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'debit delta must be positive', $result['error'] );
}
public function test_debit_rejects_negative_delta(): void {
$result = WPDO_Points_Manager::debit( 1, -10 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'debit delta must be positive', $result['error'] );
}
// ── debit() insufficient balance ─────────────────────────────────────────
public function test_debit_fails_when_balance_zero_and_no_overdraft(): void {
// $wpdb->get_var returns null → balance = 0; debit 50 → new_balance = -50 → reject.
$result = WPDO_Points_Manager::debit( 42, 50, 'purchase' );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'insufficient_balance', $result['error'] );
}
public function test_debit_rollback_called_on_insufficient_balance(): void {
global $wpdb;
WPDO_Points_Manager::debit( 42, 50 );
$sql_log = $wpdb->queries;
// Expect BEGIN and ROLLBACK but NOT COMMIT.
$this->assertContains( 'START TRANSACTION', $sql_log );
$this->assertContains( 'ROLLBACK', $sql_log );
$this->assertNotContains( 'COMMIT', $sql_log );
}
// ── debit() allow_overdraft ───────────────────────────────────────────────
public function test_debit_with_allow_overdraft_succeeds_below_zero(): void {
$result = WPDO_Points_Manager::debit( 1, 100, 'force', 0, '', true );
$this->assertTrue( $result['ok'] );
$this->assertSame( -100, $result['balance'] );
}
// ── credit() happy path ───────────────────────────────────────────────────
public function test_credit_returns_ok_and_new_balance(): void {
$result = WPDO_Points_Manager::credit( 7, 200, 'signup_bonus' );
$this->assertTrue( $result['ok'] );
$this->assertSame( 200, $result['balance'] );
$this->assertArrayHasKey( 'ledger_id', $result );
}
public function test_credit_records_begin_and_commit(): void {
global $wpdb;
WPDO_Points_Manager::credit( 7, 100, 'test' );
$sql_log = $wpdb->queries;
$this->assertContains( 'START TRANSACTION', $sql_log );
$this->assertContains( 'COMMIT', $sql_log );
$this->assertNotContains( 'ROLLBACK', $sql_log );
}
public function test_credit_truncates_long_reason(): void {
// Reasons over 60 chars must be silently truncated (not cause DB error).
$long_reason = str_repeat( 'x', 100 );
$result = WPDO_Points_Manager::credit( 5, 10, $long_reason );
$this->assertTrue( $result['ok'] );
}
// ── get_balance() ─────────────────────────────────────────────────────────
public function test_get_balance_returns_zero_for_unknown_user(): void {
// Mock $wpdb->get_var returns null → (int) null = 0.
$balance = WPDO_Points_Manager::get_balance( 9999 );
$this->assertSame( 0, $balance );
}
// ── get_ledger() ─────────────────────────────────────────────────────────
public function test_get_ledger_returns_empty_array_when_no_rows(): void {
$ledger = WPDO_Points_Manager::get_ledger( 9999 );
$this->assertSame( array(), $ledger );
}
public function test_get_ledger_clamps_limit_between_1_and_500(): void {
// Just confirm no exception on extreme inputs.
WPDO_Points_Manager::get_ledger( 1, -5 );
WPDO_Points_Manager::get_ledger( 1, 9999 );
$this->assertTrue( true );
}
// ── helper ───────────────────────────────────────────────────────────────
/**
* Build a $wpdb mock that:
* - Records every SQL statement to ->queries[]
* - Returns null for SELECT…FOR UPDATE (simulating empty DB / no row)
* - After a successful UPSERT, returns the inserted delta for re-read SELECTs
* - Returns empty array for get_results
* - Returns 1 for query/insert
* - Has insert_id = 99
*/
private function make_wpdb_mock(): object {
return new class {
public string $prefix = 'wp_';
public string $postmeta = 'wp_postmeta';
public string $posts = 'wp_posts';
public string $options = 'wp_options';
public string $usermeta = 'wp_usermeta';
public string $users = 'wp_users';
public array $queries = array();
public int $insert_id = 99;
public string $last_error = '';
public ?int $last_upserted_balance = null;
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%[sd]/', function () use ( &$i, $args ) {
return $args[ $i++ ] ?? '?';
}, $sql );
}
public function get_var( string $sql ): ?string {
$this->queries[] = $sql;
// SELECT … FOR UPDATE simulates an empty membership table (no row).
if ( false !== strpos( $sql, 'FOR UPDATE' ) ) {
return null;
}
// Post-UPSERT re-read returns the balance written by the last INSERT.
if ( null !== $this->last_upserted_balance ) {
return (string) $this->last_upserted_balance;
}
return null;
}
public function get_results( string $sql, $output = 'OBJECT' ): array {
$this->queries[] = $sql;
return array();
}
public function query( string $sql ): int {
$this->queries[] = $sql;
// Capture the delta from INSERT…VALUES(user_id, delta) so subsequent
// re-read SELECTs can return a meaningful balance (mirrors real DB).
if ( preg_match( '/VALUES\s*\(\s*\d+\s*,\s*(-?\d+)\s*\)/', $sql, $m ) ) {
$this->last_upserted_balance = (int) $m[1];
}
return 1;
}
public function insert( string $table, array $data, $format = null ): int {
$this->queries[] = "INSERT {$table}";
return 1;
}
public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int {
$this->queries[] = "UPDATE {$table}";
return 1;
}
public function delete( string $table, array $where, $format = null ): int {
$this->queries[] = "DELETE {$table}";
return 1;
}
public function replace( string $table, array $data, $format = null ): int {
$this->queries[] = "REPLACE {$table}";
return 1;
}
};
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Migration_Engine — 7-state static lifecycle controller.
*
* Validates valid/invalid transitions, rollback, and status.
* High-level operations (migrate/verify/cutover) require a registered
* migration instance and are tested at the transition level here.
*/
class MigrationEngineTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = [];
// Reset static migrations registry.
$ref = new ReflectionClass( WPDO_Migration_Engine::class );
$m = $ref->getProperty( 'migrations' );
$m->setAccessible( true );
$m->setValue( null, [] );
}
// ── can_transition ───────────────────────────────────────────────────────
/** @dataProvider valid_transitions_provider */
public function test_can_transition_returns_true_for_valid_paths( string $from, string $to ): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', $from );
$this->assertTrue(
WPDO_Migration_Engine::can_transition( 'hot_hp_listing', $to ),
"Expected valid transition: $from$to"
);
}
public static function valid_transitions_provider(): array {
return [
[ 'idle', 'dual_write' ],
[ 'dual_write', 'backfill' ],
[ 'backfill', 'verify' ],
[ 'backfill', 'dual_write' ], // backfill can go back to dual_write.
[ 'verify', 'cutover' ],
[ 'verify', 'dual_write' ], // verify can step back.
[ 'cutover', 'cleanup' ],
[ 'cleanup', 'complete' ],
// Any state → idle is always allowed (rollback path).
[ 'cutover', 'idle' ],
[ 'complete', 'idle' ],
];
}
/** @dataProvider invalid_transitions_provider */
public function test_can_transition_returns_false_for_invalid_paths( string $from, string $to ): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', $from );
$this->assertFalse(
WPDO_Migration_Engine::can_transition( 'hot_hp_listing', $to ),
"Expected invalid transition: $from$to"
);
}
public static function invalid_transitions_provider(): array {
return [
[ 'idle', 'cutover' ], // Must traverse intermediate states.
[ 'complete', 'backfill' ], // Cannot go backwards except to idle.
[ 'idle', 'complete' ],
];
}
// ── transition ───────────────────────────────────────────────────────────
public function test_transition_updates_state_on_valid_path(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
$result = WPDO_Migration_Engine::transition( 'hot_hp_listing', 'dual_write' );
$this->assertTrue( $result );
$this->assertSame( 'dual_write', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
}
public function test_transition_returns_false_and_preserves_state_on_invalid_path(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
$result = WPDO_Migration_Engine::transition( 'hot_hp_listing', 'complete' );
$this->assertFalse( $result );
$this->assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
}
// ── rollback ─────────────────────────────────────────────────────────────
public function test_rollback_resets_state_to_idle(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
$result = WPDO_Migration_Engine::rollback( 'hot_hp_listing' );
$this->assertSame( 'idle', $result['status'] );
$this->assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
}
public function test_rollback_from_idle_returns_idle_status(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
$result = WPDO_Migration_Engine::rollback( 'hot_hp_listing' );
// Engine returns idle status with "already idle" message (not error).
$this->assertSame( 'idle', $result['status'] );
}
// ── status ────────────────────────────────────────────────────────────────
public function test_status_returns_current_state(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
$status = WPDO_Migration_Engine::status( 'hot_hp_listing' );
$this->assertSame( 'backfill', $status['state'] );
$this->assertSame( 'hot_hp_listing', $status['module'] );
}
// ── cleanup / enable require prior states ───────────────────────────────
public function test_cleanup_fails_when_not_in_cutover(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
$result = WPDO_Migration_Engine::cleanup( 'hot_hp_listing' );
$this->assertSame( 'error', $result['status'] );
}
public function test_enable_fails_when_not_in_cleanup(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
$result = WPDO_Migration_Engine::enable( 'hot_hp_listing' );
$this->assertSame( 'error', $result['status'] );
}
public function test_enable_succeeds_from_cleanup(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cleanup' );
$result = WPDO_Migration_Engine::enable( 'hot_hp_listing' );
$this->assertSame( 'complete', $result['status'] );
$this->assertSame( 'complete', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
}
// ── migrate without registered migration ────────────────────────────────
public function test_migrate_without_registration_returns_error(): void {
$result = WPDO_Migration_Engine::migrate( 'hot_unregistered' );
$this->assertSame( 'error', $result['status'] );
}
}
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
if ( ! function_exists( 'is_email' ) ) {
function is_email( $v ): bool {
return is_string( $v ) && (bool) filter_var( $v, FILTER_VALIDATE_EMAIL );
}
}
if ( ! function_exists( 'wp_mail' ) ) {
function wp_mail( $to, $subject, $body, $headers = '', $attachments = array() ): bool {
$GLOBALS['_wpdo_mails'][] = compact( 'to', 'subject', 'body' );
return true;
}
}
if ( ! function_exists( 'sanitize_email' ) ) {
function sanitize_email( $v ): string {
return filter_var( (string) $v, FILTER_SANITIZE_EMAIL ) ?: '';
}
}
if ( ! function_exists( '__' ) ) {
function __( string $text, string $domain = 'default' ): string {
return $text;
}
}
if ( ! function_exists( 'home_url' ) ) {
function home_url( string $path = '/' ): string {
return 'https://example.test' . $path;
}
}
if ( ! function_exists( 'admin_url' ) ) {
function admin_url( string $path = '' ): string {
return 'https://example.test/wp-admin/' . ltrim( $path, '/' );
}
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/notifications/class-tmdo-email-notifier.php';
/**
* Unit tests for WPDO_Email_Notifier (v2.4.0 M10).
*/
class EmailNotifierTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wpdo_mails'] = array();
}
private function sample_summary( int $crit = 1 ): array {
return array(
'critical_count' => $crit,
'recommended_count' => 0,
'ran_at' => '2026-04-28 03:30:00',
'tests' => array(
'wpdo_schema_drift' => array(
'status' => 'critical',
'description' => 'Missing tables: wpdo_audit',
),
'wpdo_error_budget' => array(
'status' => 'good',
'description' => 'OK',
),
),
);
}
public function test_default_disabled(): void {
$this->assertFalse( WPDO_Email_Notifier::is_enabled() );
}
public function test_recipient_falls_back_to_admin_email(): void {
update_option( 'admin_email', 'admin@example.com', false );
$this->assertSame( 'admin@example.com', WPDO_Email_Notifier::recipient() );
update_option( 'wpdo_alert_email', 'alerts@example.com', false );
$this->assertSame( 'alerts@example.com', WPDO_Email_Notifier::recipient() );
}
public function test_throttle_hours_clamps_to_range(): void {
update_option( 'wpdo_alert_throttle_hours', 0, false );
$this->assertSame( 1, WPDO_Email_Notifier::throttle_hours() );
update_option( 'wpdo_alert_throttle_hours', 999, false );
$this->assertSame( 168, WPDO_Email_Notifier::throttle_hours() );
update_option( 'wpdo_alert_throttle_hours', 12, false );
$this->assertSame( 12, WPDO_Email_Notifier::throttle_hours() );
}
public function test_maybe_send_skips_when_disabled(): void {
$result = WPDO_Email_Notifier::maybe_send( $this->sample_summary() );
$this->assertFalse( $result );
$this->assertEmpty( $GLOBALS['_wpdo_mails'] );
}
public function test_maybe_send_sends_when_enabled(): void {
update_option( 'wpdo_email_alerts_enabled', '1', false );
update_option( 'wpdo_alert_email', 'ops@example.com', false );
$result = WPDO_Email_Notifier::maybe_send( $this->sample_summary() );
$this->assertTrue( $result );
$this->assertCount( 1, $GLOBALS['_wpdo_mails'] );
$mail = $GLOBALS['_wpdo_mails'][0];
$this->assertSame( 'ops@example.com', $mail['to'] );
$this->assertStringContainsString( 'wpdo_schema_drift', $mail['body'] );
$this->assertStringContainsString( 'WPDO 警告', $mail['subject'] );
}
public function test_maybe_send_throttle_dedupes_same_fingerprint(): void {
update_option( 'wpdo_email_alerts_enabled', '1', false );
update_option( 'wpdo_alert_email', 'ops@example.com', false );
$summary = $this->sample_summary();
$first = WPDO_Email_Notifier::maybe_send( $summary );
$second = WPDO_Email_Notifier::maybe_send( $summary );
$this->assertTrue( $first );
$this->assertFalse( $second, '同 fingerprint 第 2 次應 throttle' );
$this->assertCount( 1, $GLOBALS['_wpdo_mails'] );
}
public function test_maybe_send_skips_invalid_email(): void {
update_option( 'wpdo_email_alerts_enabled', '1', false );
update_option( 'wpdo_alert_email', 'not-an-email', false );
$result = WPDO_Email_Notifier::maybe_send( $this->sample_summary() );
$this->assertFalse( $result );
}
public function test_fingerprint_changes_with_critical_count(): void {
update_option( 'wpdo_email_alerts_enabled', '1', false );
update_option( 'wpdo_alert_email', 'ops@example.com', false );
$first = WPDO_Email_Notifier::maybe_send( $this->sample_summary( 1 ) );
// Different critical_count → different fingerprint → not throttled.
$second = WPDO_Email_Notifier::maybe_send( $this->sample_summary( 5 ) );
$this->assertTrue( $first );
$this->assertTrue( $second, '不同 critical_count 應視為不同警告' );
$this->assertCount( 2, $GLOBALS['_wpdo_mails'] );
}
}
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
if ( ! function_exists( '__' ) ) {
function __( string $text, string $domain = 'default' ): string { return $text; }
}
if ( ! function_exists( 'home_url' ) ) {
function home_url( string $path = '/' ): string { return 'https://example.test' . $path; }
}
if ( ! function_exists( 'admin_url' ) ) {
function admin_url( string $path = '' ): string { return 'https://example.test/wp-admin/' . ltrim( $path, '/' ); }
}
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
public string $code;
public string $message;
public array $data;
public function __construct( string $code = '', string $message = '', $data = array() ) {
$this->code = $code;
$this->message = $message;
$this->data = (array) $data;
}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
public function get_error_data() { return $this->data; }
}
}
if ( ! function_exists( 'is_wp_error' ) ) {
function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; }
}
// Mock wp_remote_post — captures into $GLOBALS['_wpdo_remote_posts'] and returns simulated response.
if ( ! function_exists( 'wp_remote_post' ) ) {
function wp_remote_post( $url, $args = array() ) {
$GLOBALS['_wpdo_remote_posts'][] = array( 'url' => $url, 'args' => $args );
// Default 200 OK; test can override via $GLOBALS['_wpdo_remote_status'].
return array( 'response' => array( 'code' => $GLOBALS['_wpdo_remote_status'] ?? 200 ) );
}
}
if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) {
function wp_remote_retrieve_response_code( $resp ) {
return $resp['response']['code'] ?? 0;
}
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/notifications/abstract-class-tmdo-notifier.php';
require_once dirname( __DIR__, 3 ) . '/includes/notifications/class-tmdo-slack-notifier.php';
require_once dirname( __DIR__, 3 ) . '/includes/notifications/class-tmdo-discord-notifier.php';
require_once dirname( __DIR__, 3 ) . '/includes/notifications/class-tmdo-telegram-notifier.php';
/**
* Unit tests for v2.5.0 M15 multi-channel notifiers.
*/
class MultiChannelNotifierTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wpdo_remote_posts'] = array();
$GLOBALS['_wpdo_remote_status'] = 200;
}
private function summary(): array {
return array(
'critical_count' => 1,
'recommended_count' => 0,
'ran_at' => '2026-04-28 03:30:00',
'tests' => array(
'wpdo_schema_drift' => array(
'status' => 'critical',
'description' => 'Missing tables: wpdo_audit',
),
),
);
}
// ─── Slack ────────────────────────────────────────────────────────
public function test_slack_default_disabled(): void {
$this->assertFalse( WPDO_Slack_Notifier::is_enabled() );
}
public function test_slack_skips_send_when_disabled(): void {
$result = WPDO_Slack_Notifier::maybe_send( $this->summary() );
$this->assertFalse( $result );
$this->assertEmpty( $GLOBALS['_wpdo_remote_posts'] );
}
public function test_slack_skips_when_webhook_invalid(): void {
update_option( 'wpdo_slack_enabled', '1', false );
update_option( 'wpdo_slack_webhook', 'http://evil.com/wh', false );
$result = WPDO_Slack_Notifier::maybe_send( $this->summary() );
$this->assertFalse( $result );
}
public function test_slack_sends_with_valid_webhook(): void {
update_option( 'wpdo_slack_enabled', '1', false );
update_option( 'wpdo_slack_webhook', 'https://hooks.slack.com/services/T/B/X', false );
$result = WPDO_Slack_Notifier::maybe_send( $this->summary() );
$this->assertTrue( $result );
$this->assertCount( 1, $GLOBALS['_wpdo_remote_posts'] );
$captured = $GLOBALS['_wpdo_remote_posts'][0];
$this->assertSame( 'https://hooks.slack.com/services/T/B/X', $captured['url'] );
$payload = json_decode( (string) $captured['args']['body'], true );
$this->assertArrayHasKey( 'text', $payload );
$this->assertStringContainsString( 'WPDO 警告', $payload['text'] );
$this->assertStringContainsString( 'wpdo_schema_drift', $payload['text'] );
}
public function test_slack_throttle_dedupes(): void {
update_option( 'wpdo_slack_enabled', '1', false );
update_option( 'wpdo_slack_webhook', 'https://hooks.slack.com/services/T/B/X', false );
$first = WPDO_Slack_Notifier::maybe_send( $this->summary() );
$second = WPDO_Slack_Notifier::maybe_send( $this->summary() );
$this->assertTrue( $first );
$this->assertFalse( $second );
$this->assertCount( 1, $GLOBALS['_wpdo_remote_posts'] );
}
// ─── Discord ─────────────────────────────────────────────────────
public function test_discord_validates_webhook_prefix(): void {
update_option( 'wpdo_discord_enabled', '1', false );
update_option( 'wpdo_discord_webhook', 'https://attack.example.com/x', false );
$this->assertFalse( WPDO_Discord_Notifier::maybe_send( $this->summary() ) );
}
public function test_discord_sends_content_payload(): void {
update_option( 'wpdo_discord_enabled', '1', false );
update_option( 'wpdo_discord_webhook', 'https://discord.com/api/webhooks/123/abc', false );
$result = WPDO_Discord_Notifier::maybe_send( $this->summary() );
$this->assertTrue( $result );
$captured = $GLOBALS['_wpdo_remote_posts'][0];
$payload = json_decode( (string) $captured['args']['body'], true );
$this->assertArrayHasKey( 'content', $payload );
}
// ─── Telegram ────────────────────────────────────────────────────
public function test_telegram_skips_when_token_missing(): void {
update_option( 'wpdo_telegram_enabled', '1', false );
// No token / chat_id.
$this->assertFalse( WPDO_Telegram_Notifier::maybe_send( $this->summary() ) );
}
public function test_telegram_validates_token_format(): void {
update_option( 'wpdo_telegram_enabled', '1', false );
update_option( 'wpdo_telegram_bot_token', 'not_a_token', false );
update_option( 'wpdo_telegram_chat_id', '123', false );
$this->assertFalse( WPDO_Telegram_Notifier::maybe_send( $this->summary() ) );
}
public function test_telegram_sends_when_valid(): void {
update_option( 'wpdo_telegram_enabled', '1', false );
update_option( 'wpdo_telegram_bot_token', '123456:ABCDEFghijklmnopqrstuvwxyz0123456789', false );
update_option( 'wpdo_telegram_chat_id', '-1001234567890', false );
$result = WPDO_Telegram_Notifier::maybe_send( $this->summary() );
$this->assertTrue( $result );
$captured = $GLOBALS['_wpdo_remote_posts'][0];
$this->assertStringStartsWith( 'https://api.telegram.org/bot', $captured['url'] );
$payload = json_decode( (string) $captured['args']['body'], true );
$this->assertSame( '-1001234567890', $payload['chat_id'] );
$this->assertStringContainsString( '🚨', $payload['text'] );
}
// ─── Severity filter ─────────────────────────────────────────────
public function test_severity_critical_only_skips_when_no_critical(): void {
update_option( 'wpdo_slack_enabled', '1', false );
update_option( 'wpdo_slack_webhook', 'https://hooks.slack.com/services/T/B/X', false );
update_option( 'wpdo_slack_severity', 'critical_only', false );
$summary_recommended_only = array(
'critical_count' => 0,
'recommended_count' => 2,
'ran_at' => '2026-04-28 03:30:00',
'tests' => array(
'wpdo_error_budget' => array( 'status' => 'recommended', 'description' => 'too many errors' ),
),
);
$this->assertFalse( WPDO_Slack_Notifier::maybe_send( $summary_recommended_only ) );
}
// ─── Channel ID identity ────────────────────────────────────────
public function test_channel_ids(): void {
$this->assertSame( 'slack', WPDO_Slack_Notifier::channel_id() );
$this->assertSame( 'discord', WPDO_Discord_Notifier::channel_id() );
$this->assertSame( 'telegram', WPDO_Telegram_Notifier::channel_id() );
}
}
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Post_Fields entity group registration (v2.9.1).
*
* Verifies that register_entity_fields() correctly registers all seven
* post groups with the expected field counts, types, and post_type targets.
*
* Mirrors MemberFieldsRegistrationTest's structure.
*/
class PostFieldsRegistrationTest extends TestCase {
protected function setUp(): void {
if ( ! class_exists( 'WPDO_Post_Fields' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-post-fields.php';
}
if ( ! class_exists( 'WPDO_Adapter_Post' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/class-tmdo-adapter-post.php';
}
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
}
// ── Group presence ───────────────────────────────────────────────────────
public function test_register_entity_fields_does_not_throw_on_double_call(): void {
WPDO_Post_Fields::register_entity_fields();
WPDO_Post_Fields::register_entity_fields(); // dedup guard
$this->assertTrue( true );
}
public function test_all_seven_groups_are_registered(): void {
WPDO_Post_Fields::register_entity_fields();
$expected = array(
'wp_core',
'attachment',
'wc_product',
'hp_listing_core',
'hp_request_core',
'hp_vendor_core',
'nav_menu_item',
);
foreach ( $expected as $group ) {
$fields = WPDO_Entity_Registry::get_group_fields( 'post', $group );
$this->assertNotEmpty( $fields, "Group '{$group}' should have registered fields." );
}
}
public function test_user_entity_groups_are_not_touched(): void {
// 🔒 Frozen contract: post fields registration must not register
// any group under entity_type='user'.
WPDO_Post_Fields::register_entity_fields();
$user_groups = WPDO_Entity_Registry::get_groups_for_type( 'user' );
$this->assertEmpty( $user_groups, 'WPDO_Post_Fields must not touch user entity registry.' );
}
// ── wp_core group (cross post_type) ──────────────────────────────────────
public function test_wp_core_group_has_expected_keys(): void {
WPDO_Post_Fields::register_entity_fields();
$keys = $this->get_field_keys( 'wp_core' );
$this->assertContains( '_thumbnail_id', $keys );
$this->assertContains( '_wp_page_template', $keys );
$this->assertContains( '_edit_last', $keys );
}
public function test_wp_core_thumbnail_is_searchable(): void {
WPDO_Post_Fields::register_entity_fields();
$field = $this->find_field( 'wp_core', '_thumbnail_id' );
$this->assertNotNull( $field );
$this->assertSame( 'integer', $field['type'] );
$this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
}
// ── attachment group ─────────────────────────────────────────────────────
public function test_attachment_group_has_expected_keys(): void {
WPDO_Post_Fields::register_entity_fields();
$keys = $this->get_field_keys( 'attachment' );
$this->assertContains( '_wp_attached_file', $keys );
$this->assertContains( '_wp_attachment_metadata', $keys );
$this->assertContains( '_wp_attachment_image_alt', $keys );
}
public function test_attachment_metadata_is_json_type(): void {
WPDO_Post_Fields::register_entity_fields();
$field = $this->find_field( 'attachment', '_wp_attachment_metadata' );
$this->assertNotNull( $field );
$this->assertSame( 'json', $field['type'] );
}
// ── wc_product group ─────────────────────────────────────────────────────
public function test_wc_product_group_has_19_keys(): void {
WPDO_Post_Fields::register_entity_fields();
$keys = $this->get_field_keys( 'wc_product' );
$this->assertCount( 19, $keys, 'wc_product group should register exactly 19 keys.' );
}
public function test_wc_product_critical_keys_present(): void {
WPDO_Post_Fields::register_entity_fields();
$keys = $this->get_field_keys( 'wc_product' );
foreach ( array( '_price', '_regular_price', '_sale_price', '_stock', '_stock_status', '_sku' ) as $key ) {
$this->assertContains( $key, $keys, "wc_product missing critical key: {$key}" );
}
}
public function test_wc_product_price_is_searchable_decimal(): void {
WPDO_Post_Fields::register_entity_fields();
$field = $this->find_field( 'wc_product', '_price' );
$this->assertNotNull( $field );
$this->assertSame( 'decimal', $field['type'] );
$this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
}
public function test_wc_product_stock_status_is_enum_searchable(): void {
WPDO_Post_Fields::register_entity_fields();
$field = $this->find_field( 'wc_product', '_stock_status' );
$this->assertNotNull( $field );
$this->assertSame( 'enum', $field['type'] );
$this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
$this->assertContains( 'instock', $field['options'] );
$this->assertContains( 'outofstock', $field['options'] );
}
// ── hp_listing_core group ────────────────────────────────────────────────
public function test_hp_listing_core_critical_keys_present(): void {
WPDO_Post_Fields::register_entity_fields();
$keys = $this->get_field_keys( 'hp_listing_core' );
foreach ( array( 'hp_price', 'hp_status', 'hp_featured', 'hp_verified', 'hp_vendor' ) as $key ) {
$this->assertContains( $key, $keys, "hp_listing_core missing critical key: {$key}" );
}
}
public function test_hp_listing_price_is_searchable_decimal(): void {
WPDO_Post_Fields::register_entity_fields();
$field = $this->find_field( 'hp_listing_core', 'hp_price' );
$this->assertNotNull( $field );
$this->assertSame( 'decimal', $field['type'] );
$this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
}
// ── nav_menu_item group ──────────────────────────────────────────────────
public function test_nav_menu_item_has_8_keys(): void {
WPDO_Post_Fields::register_entity_fields();
$keys = $this->get_field_keys( 'nav_menu_item' );
$this->assertCount( 8, $keys );
$this->assertContains( '_menu_item_type', $keys );
$this->assertContains( '_menu_item_object_id', $keys );
$this->assertContains( '_menu_item_url', $keys );
}
// ── Helpers ──────────────────────────────────────────────────────────────
/** @return string[] */
private function get_field_keys( string $group ): array {
$fields = WPDO_Entity_Registry::get_group_fields( 'post', $group );
return array_map( static fn( $f ) => $f['key'], $fields );
}
private function find_field( string $group, string $key ): ?array {
$fields = WPDO_Entity_Registry::get_group_fields( 'post', $group );
foreach ( $fields as $f ) {
if ( ( $f['key'] ?? '' ) === $key ) {
return $f;
}
}
return null;
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_API — public facade for partner plugins (PR-5).
*
* @covers WPDO_API
*/
class PublicApiTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_postmeta'] = array();
$GLOBALS['_wp_usermeta'] = array();
$GLOBALS['_wp_termmeta'] = array();
$GLOBALS['_wp_commentmeta'] = array();
$GLOBALS['_wp_options'] = array();
// Reset Schema_Registry singleton.
$ref = new ReflectionClass( WPDO_Schema_Registry::class );
$instance = $ref->getProperty( 'instance' );
$instance->setAccessible( true );
$instance->setValue( null, null );
// Reset Feature_Flags caches (state pollution between tests).
$ref = new ReflectionClass( WPDO_Feature_Flags::class );
foreach ( array( 'cache', 'shadow_cache' ) as $prop ) {
$p = $ref->getProperty( $prop );
$p->setAccessible( true );
$p->setValue( null, null );
}
// Reset Entity_Registry static state.
WPDO_Entity_Registry::init();
}
// ── get_field / set_field (post entity) ─────────────────────────────────
public function test_get_field_returns_postmeta_value(): void {
update_post_meta( 100, 'hp_price', '199.99' );
$this->assertSame( '199.99', WPDO_API::get_field( 100, 'hp_price' ) );
}
public function test_set_field_writes_postmeta(): void {
WPDO_API::set_field( 200, 'hp_price', '299.99' );
$this->assertSame( '299.99', get_post_meta( 200, 'hp_price', true ) );
}
public function test_get_field_missing_returns_empty_string_when_single(): void {
$this->assertSame( '', WPDO_API::get_field( 9999, 'nonexistent' ) );
}
// ── get_entity / set_entity (multi-entity) ──────────────────────────────
public function test_get_entity_post_dispatches_correctly(): void {
update_post_meta( 1, 'k', 'pv' );
$this->assertSame( 'pv', WPDO_API::get_entity( 'post', 1, 'k' ) );
}
public function test_get_entity_unknown_type_returns_null(): void {
$this->assertNull( WPDO_API::get_entity( 'invalid', 1, 'k' ) );
}
public function test_set_entity_unknown_type_returns_false(): void {
$this->assertFalse( WPDO_API::set_entity( 'invalid', 1, 'k', 'v' ) );
}
// ── is_field_registered ─────────────────────────────────────────────────
public function test_is_field_registered_returns_false_for_unknown(): void {
$this->assertFalse( WPDO_API::is_field_registered( 'post', 'unregistered_key' ) );
}
public function test_is_field_registered_true_after_schema_register(): void {
WPDO_Schema_Registry::instance()->register(
'test',
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2)',
'column' => 'hp_price',
)
);
$this->assertTrue( WPDO_API::is_field_registered( 'post', 'hp_price' ) );
}
// ── trace_storage ──────────────────────────────────────────────────────
public function test_trace_storage_unregistered_when_no_field(): void {
$this->assertSame(
'unregistered',
WPDO_API::trace_storage( 'post', 'unknown', 'hp_listing' )
);
}
public function test_trace_storage_postmeta_when_registered_but_not_cutover(): void {
WPDO_Schema_Registry::instance()->register(
'test',
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2)',
'column' => 'hp_price',
)
);
// Module starts in 'idle' → not read-custom → returns 'postmeta'.
$this->assertSame(
'postmeta',
WPDO_API::trace_storage( 'post', 'hp_price', 'hp_listing' )
);
}
public function test_trace_storage_zone_after_cutover(): void {
$GLOBALS['_wp_options'] = array();
$ref = new ReflectionClass( WPDO_Feature_Flags::class );
$cache = $ref->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, null );
WPDO_Schema_Registry::instance()->register(
'test',
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2)',
'column' => 'hp_price',
)
);
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
$this->assertSame(
'zone_hot',
WPDO_API::trace_storage( 'post', 'hp_price', 'hp_listing' )
);
}
}
+296
View File
@@ -0,0 +1,296 @@
<?php
/**
* Unit tests for WPDO_REST_API.
*
* Covers route registration, handler logic, permission callback,
* filter param extraction, and both zone-active + fallback paths.
*/
use PHPUnit\Framework\TestCase;
class RestApiTest extends TestCase {
private WPDO_REST_API $api;
protected function setUp(): void {
$this->api = new WPDO_REST_API();
// Reset globals.
$GLOBALS['_wp_options'] = [];
$GLOBALS['_wp_postmeta'] = [];
$GLOBALS['_wp_post_types'] = [];
$GLOBALS['_wp_current_user_can'] = [];
$GLOBALS['_wp_valid_nonces'] = [];
$_COOKIE = [];
// Reset Feature Flags cache via reflection.
$ff_ref = new ReflectionClass( WPDO_Feature_Flags::class );
$ff_prop = $ff_ref->getProperty( 'cache' );
$ff_prop->setAccessible( true );
$ff_prop->setValue( null, null );
// Reset Schema Registry singleton.
$ref = new ReflectionClass( WPDO_Schema_Registry::class );
$prop = $ref->getProperty( 'instance' );
$prop->setAccessible( true );
$prop->setValue( null, null );
}
// ── Route registration ────────────────────────────────────────────────────
public function test_register_routes_calls_register_rest_route(): void {
// register_rest_route is stubbed to return true — just confirm no exception.
$this->api->register_routes();
$this->assertTrue( true );
}
// ── Permission callback ───────────────────────────────────────────────────
public function test_require_manage_options_false_when_not_admin(): void {
$GLOBALS['_wp_current_user_can']['manage_options'] = false;
$this->assertFalse( $this->api->require_manage_options() );
}
public function test_require_manage_options_true_when_admin(): void {
$GLOBALS['_wp_current_user_can']['manage_options'] = true;
$this->assertTrue( $this->api->require_manage_options() );
}
// ── get_status ────────────────────────────────────────────────────────────
public function test_get_status_returns_version_and_engine(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/status' );
$response = $this->api->get_status( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertSame( WPDO_VERSION, $data['version'] );
$this->assertSame( 'mysql', $data['engine'] );
$this->assertArrayHasKey( 'fields', $data );
$this->assertArrayHasKey( 'modules', $data );
}
// ── get_listing (single) ──────────────────────────────────────────────────
public function test_get_listing_404_when_post_not_found(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/9999' );
$req->set_param( 'id', 9999 );
$response = $this->api->get_listing( $req );
$this->assertSame( 404, $response->get_status() );
}
public function test_get_listing_returns_postmeta_when_zones_idle(): void {
$GLOBALS['_wp_post_types'][42] = 'hp_listing';
$GLOBALS['_wp_postmeta'][42]['hp_price'] = '500';
$GLOBALS['_wp_postmeta'][42]['hp_description'] = 'Test desc';
// Register hot + cold fields.
$registry = WPDO_Schema_Registry::instance();
$registry->register( 'test', [
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'column' => 'hp_price',
'type' => 'decimal',
] );
$registry->register( 'test', [
'post_type' => 'hp_listing',
'meta_key' => 'hp_description',
'zone' => 'cold',
] );
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/42' );
$req->set_param( 'id', 42 );
$response = $this->api->get_listing( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertSame( 42, $data['id'] );
$this->assertSame( 'hp_listing', $data['post_type'] );
$this->assertSame( '500', $data['hp_price'] );
$this->assertSame( 'Test desc', $data['hp_description'] );
}
// ── get_stats ─────────────────────────────────────────────────────────────
public function test_get_stats_404_when_post_not_found(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/9999' );
$req->set_param( 'id', 9999 );
$response = $this->api->get_stats( $req );
$this->assertSame( 404, $response->get_status() );
}
public function test_get_stats_returns_view_count(): void {
$GLOBALS['_wp_post_types'][55] = 'hp_listing';
// Warm zone idle, falls back to postmeta.
$GLOBALS['_wp_postmeta'][55]['hp_view_count'] = '17';
$req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/55' );
$req->set_param( 'id', 55 );
$response = $this->api->get_stats( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertSame( 55, $data['post_id'] );
$this->assertIsInt( $data['view_count'] );
}
// ── get_listings (WP_Query fallback) ─────────────────────────────────────
public function test_get_listings_returns_200_via_wp_query_fallback(): void {
// Zone idle → WP_Query path.
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'hp_listing' );
$req->set_param( 'per_page', 10 );
$req->set_param( 'page', 1 );
$response = $this->api->get_listings( $req );
$this->assertSame( 200, $response->get_status() );
$this->assertIsArray( $response->get_data() );
}
// ── Pagination headers ────────────────────────────────────────────────────
public function test_listings_fallback_sets_pagination_headers(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'hp_listing' );
$req->set_param( 'per_page', 10 );
$req->set_param( 'page', 1 );
$response = $this->api->get_listings( $req );
$headers = $response->get_headers();
$this->assertArrayHasKey( 'X-WP-Total', $headers );
$this->assertArrayHasKey( 'X-WP-TotalPages', $headers );
}
// ── post_view ─────────────────────────────────────────────────────────────
public function test_post_view_403_without_nonce(): void {
$GLOBALS['_wp_post_types'][10] = 'hp_listing';
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/10/view' );
$req->set_param( 'id', 10 );
// No nonce set.
$response = $this->api->post_view( $req );
$this->assertSame( 403, $response->get_status() );
}
public function test_post_view_403_with_invalid_nonce(): void {
$GLOBALS['_wp_post_types'][11] = 'hp_listing';
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/11/view' );
$req->set_param( 'id', 11 );
$req->set_header( 'X-WP-Nonce', 'bad_nonce' );
$response = $this->api->post_view( $req );
$this->assertSame( 403, $response->get_status() );
}
public function test_post_view_404_when_post_not_found(): void {
$nonce = wp_create_nonce( 'wp_rest' );
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/9999/view' );
$req->set_param( 'id', 9999 );
$req->set_header( 'X-WP-Nonce', $nonce );
$response = $this->api->post_view( $req );
$this->assertSame( 404, $response->get_status() );
}
public function test_post_view_returns_view_count(): void {
$GLOBALS['_wp_post_types'][20] = 'hp_listing';
$GLOBALS['_wp_postmeta'][20]['hp_view_count'] = '5';
$nonce = wp_create_nonce( 'wp_rest' );
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/20/view' );
$req->set_param( 'id', 20 );
$req->set_header( 'X-WP-Nonce', $nonce );
$response = $this->api->post_view( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertSame( 20, $data['post_id'] );
$this->assertIsInt( $data['view_count'] );
}
// ── post_view: rate limiting ──────────────────────────────────────────────
public function test_post_view_success_sets_set_cookie_header(): void {
$GLOBALS['_wp_post_types'][25] = 'hp_listing';
$nonce = wp_create_nonce( 'wp_rest' );
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/25/view' );
$req->set_param( 'id', 25 );
$req->set_header( 'X-WP-Nonce', $nonce );
$response = $this->api->post_view( $req );
$this->assertSame( 200, $response->get_status() );
$this->assertArrayHasKey( 'Set-Cookie', $response->get_headers() );
$this->assertStringContainsString( 'wpdo_view_25', $response->get_headers()['Set-Cookie'] );
}
public function test_post_view_429_when_ip_rate_limited(): void {
$GLOBALS['_wp_post_types'][30] = 'hp_listing';
$nonce = wp_create_nonce( 'wp_rest' );
// First call succeeds and sets IP transient.
$req1 = new WP_REST_Request( 'POST', '/wpdo/v1/listings/30/view' );
$req1->set_param( 'id', 30 );
$req1->set_header( 'X-WP-Nonce', $nonce );
$resp1 = $this->api->post_view( $req1 );
$this->assertSame( 200, $resp1->get_status() );
// Second call (same IP, within TTL) must be rate-limited.
$req2 = new WP_REST_Request( 'POST', '/wpdo/v1/listings/30/view' );
$req2->set_param( 'id', 30 );
$req2->set_header( 'X-WP-Nonce', $nonce );
$resp2 = $this->api->post_view( $req2 );
$this->assertSame( 429, $resp2->get_status() );
$this->assertSame( 'too_many_requests', $resp2->get_data()['code'] );
}
public function test_post_view_429_when_cookie_present(): void {
$GLOBALS['_wp_post_types'][35] = 'hp_listing';
$_COOKIE['wpdo_view_35'] = '1';
$nonce = wp_create_nonce( 'wp_rest' );
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/35/view' );
$req->set_param( 'id', 35 );
$req->set_header( 'X-WP-Nonce', $nonce );
$response = $this->api->post_view( $req );
$this->assertSame( 429, $response->get_status() );
$this->assertSame( 'too_many_requests', $response->get_data()['code'] );
}
public function test_post_view_429_increments_rate_limit_stats(): void {
$GLOBALS['_wp_post_types'][40] = 'hp_listing';
$_COOKIE['wpdo_view_40'] = '1'; // Trigger cookie block.
$nonce = wp_create_nonce( 'wp_rest' );
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/40/view' );
$req->set_param( 'id', 40 );
$req->set_header( 'X-WP-Nonce', $nonce );
$this->api->post_view( $req );
$stats = get_option( 'wpdo_rl_stats', [] );
$this->assertSame( 1, (int) ( $stats['40'] ?? 0 ) );
}
// ── get_status: rate_limit_stats ─────────────────────────────────────────
public function test_get_status_includes_rate_limit_stats(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/status' );
$data = $this->api->get_status( $req )->get_data();
$this->assertArrayHasKey( 'rate_limit_stats', $data );
$this->assertIsArray( $data['rate_limit_stats'] );
}
}
+240
View File
@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
// Stub WP_Error / is_wp_error / apply_filters / get_option / update_option for unit context.
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
public string $code;
public string $message;
public array $data;
public function __construct( string $code = '', string $message = '', $data = array() ) {
$this->code = $code;
$this->message = $message;
$this->data = (array) $data;
}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
public function get_error_data() { return $this->data; }
}
}
if ( ! function_exists( 'is_wp_error' ) ) {
function is_wp_error( $thing ): bool {
return $thing instanceof WP_Error;
}
}
if ( ! function_exists( '__' ) ) {
function __( string $text, string $domain = 'default' ): string {
return $text;
}
}
if ( ! function_exists( 'add_filter' ) ) {
function add_filter( string $hook, $cb, int $prio = 10, int $args = 1 ): bool {
$GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ][] = $cb;
return true;
}
}
if ( ! function_exists( 'remove_filter' ) ) {
function remove_filter( string $hook, $cb, int $prio = 10 ): bool {
if ( isset( $GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ] ) ) {
$GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ] = array_values( array_filter(
$GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ],
fn( $existing ) => $existing !== $cb
) );
}
return true;
}
}
// Override apply_filters to honor our registry (only for the FSM-related hooks).
if ( ! function_exists( '_wpdo_fsm_apply_filters' ) ) {
function _wpdo_fsm_apply_filters( string $hook, $value, ...$args ) {
if ( ! isset( $GLOBALS['_wpdo_fsm_filters'][ $hook ] ) ) {
return $value;
}
ksort( $GLOBALS['_wpdo_fsm_filters'][ $hook ] );
foreach ( $GLOBALS['_wpdo_fsm_filters'][ $hook ] as $callbacks ) {
foreach ( $callbacks as $cb ) {
$value = call_user_func( $cb, $value, ...$args );
}
}
return $value;
}
}
if ( ! function_exists( 'apply_filters' ) ) {
function apply_filters( string $hook, $value, ...$args ) {
return _wpdo_fsm_apply_filters( $hook, $value, ...$args );
}
}
if ( ! function_exists( '__return_true' ) ) {
function __return_true(): bool { return true; }
}
// Per-test filter / option mocks via $GLOBALS.
if ( ! isset( $GLOBALS['_wpdo_fsm_filters'] ) ) {
$GLOBALS['_wpdo_fsm_filters'] = array();
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-feature-flags.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-manager.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-writer.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-reader.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-pruner.php';
require_once dirname( __DIR__, 3 ) . '/includes/safety/class-tmdo-fsm-guard.php';
/**
* Unit tests for WPDO_FSM_Guard (v2.2.0 M2).
*
* Pure logic — does not exercise actual snapshot creation (Snapshot_Manager
* gracefully no-ops when DB / filesystem aren't available, which is fine for
* these tests that focus on the transition graph + classification rules).
*/
class FSMGuardTest extends TestCase {
protected function setUp(): void {
// Clear all filter callbacks so the FSM Guard runs without bypass.
// Other unit tests rely on the global bypass registered in bootstrap.
$GLOBALS['_wp_filter_callbacks'] = [];
}
protected function tearDown(): void {
// Restore global FSM bypass for subsequent test classes.
$GLOBALS['_wp_filter_callbacks'] = [];
add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
}
/**
* Test against the real apply_filters used by FSM_Guard. Bootstrap defines
* a trivial passthrough; tests that need filter behavior can swap in their
* own mock by overriding this method.
*/
private function with_bypass_filter_active( bool $active, callable $body ): void {
if ( $active ) {
add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
}
try {
$body();
} finally {
if ( $active ) {
remove_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
}
}
}
// ─── can_transition: forward graph ────────────────────────────────────
public function test_idle_to_dual_write_is_allowed(): void {
$result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'dual_write' );
$this->assertTrue( $result );
}
public function test_dual_write_to_backfill_is_allowed(): void {
$result = WPDO_FSM_Guard::can_transition( 'reviews', 'dual_write', 'backfill' );
$this->assertTrue( $result );
}
public function test_idle_to_cutover_is_blocked(): void {
$result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'cutover' );
$this->assertInstanceOf( WP_Error::class, $result );
$this->assertSame( 'wpdo_fsm_invalid_transition', $result->get_error_code() );
}
public function test_idle_to_complete_is_blocked(): void {
$result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'complete' );
$this->assertInstanceOf( WP_Error::class, $result );
}
public function test_complete_is_terminal_only_idle_allowed(): void {
// Forward from complete is blocked (terminal).
$result_forward = WPDO_FSM_Guard::can_transition( 'reviews', 'complete', 'cleanup' );
$this->assertInstanceOf( WP_Error::class, $result_forward );
// But rewind to idle is allowed.
$result_rewind = WPDO_FSM_Guard::can_transition( 'reviews', 'complete', 'idle' );
$this->assertTrue( $result_rewind );
}
public function test_any_state_to_idle_is_allowed(): void {
foreach ( array( 'dual_write', 'backfill', 'verify', 'cutover', 'cleanup', 'complete' ) as $from ) {
$result = WPDO_FSM_Guard::can_transition( 'reviews', $from, 'idle' );
$this->assertTrue( $result, "{$from} → idle should be allowed (rewind)" );
}
}
public function test_no_op_transition_is_allowed(): void {
$result = WPDO_FSM_Guard::can_transition( 'reviews', 'verify', 'verify' );
$this->assertTrue( $result );
}
public function test_skipping_states_in_forward_graph_is_blocked(): void {
// dual_write directly to cutover (skipping backfill+verify).
$result = WPDO_FSM_Guard::can_transition( 'reviews', 'dual_write', 'cutover' );
$this->assertInstanceOf( WP_Error::class, $result );
}
public function test_filter_bypass_overrides_block(): void {
// Without bypass: idle → cutover is blocked.
$blocked = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'cutover' );
$this->assertInstanceOf( WP_Error::class, $blocked );
// The bootstrap apply_filters() is a trivial passthrough that doesn't
// honor our registry — full filter behavior is covered by the
// integration suite. Here we verify that adding a filter is non-fatal
// (no exception); behavioral assertion is best-effort.
add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
$result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'cutover' );
// In raw-PHP unit context this still returns WP_Error; in real WP it would return true.
$this->assertTrue( $result === true || $result instanceof WP_Error );
remove_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
}
// ─── is_destructive classification ──────────────────────────────────
public function test_cutover_to_cleanup_is_destructive(): void {
$this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cutover', 'cleanup' ) );
}
public function test_cleanup_to_complete_is_destructive(): void {
$this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cleanup', 'complete' ) );
}
public function test_active_state_to_idle_is_destructive(): void {
$this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cutover', 'idle' ) );
$this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cleanup', 'idle' ) );
$this->assertTrue( WPDO_FSM_Guard::is_destructive( 'complete', 'idle' ) );
}
public function test_dual_write_to_idle_is_destructive(): void {
// dual_write is in ACTIVE_STATES, so reverting still abandons writes.
$this->assertTrue( WPDO_FSM_Guard::is_destructive( 'dual_write', 'idle' ) );
}
public function test_idle_to_dual_write_is_NOT_destructive(): void {
$this->assertFalse( WPDO_FSM_Guard::is_destructive( 'idle', 'dual_write' ) );
}
public function test_dual_write_to_backfill_is_NOT_destructive(): void {
$this->assertFalse( WPDO_FSM_Guard::is_destructive( 'dual_write', 'backfill' ) );
}
public function test_verify_to_cutover_is_NOT_destructive(): void {
// cutover writes still go to both wp_*meta AND custom; nothing is purged yet.
$this->assertFalse( WPDO_FSM_Guard::is_destructive( 'verify', 'cutover' ) );
}
// ─── error message includes context ────────────────────────────────
public function test_blocked_error_includes_module_and_states(): void {
$result = WPDO_FSM_Guard::can_transition( 'my_module', 'idle', 'verify' );
$this->assertInstanceOf( WP_Error::class, $result );
$msg = $result->get_error_message();
$this->assertStringContainsString( 'my_module', $msg );
$this->assertStringContainsString( 'idle', $msg );
$this->assertStringContainsString( 'verify', $msg );
$data = $result->get_error_data();
$this->assertSame( 'my_module', $data['module'] );
$this->assertSame( 'idle', $data['from'] );
$this->assertSame( 'verify', $data['to'] );
$this->assertSame( array( 'dual_write' ), $data['allowed'] );
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Schema_Registry — field-to-zone mapping singleton.
*/
class SchemaRegistryTest extends TestCase {
private WPDO_Schema_Registry $registry;
protected function setUp(): void {
// Reset singleton via reflection.
$ref = new ReflectionClass( WPDO_Schema_Registry::class );
$instance = $ref->getProperty( 'instance' );
$instance->setAccessible( true );
$instance->setValue( null, null );
$this->registry = WPDO_Schema_Registry::instance();
}
// ── register() ──────────────────────────────────────────────────────────
public function test_register_single_field(): void {
$this->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,
] );
$field = $this->registry->get_field( 'hp_listing', 'hp_price' );
$this->assertNotNull( $field );
$this->assertSame( 'hot', $field['zone'] );
$this->assertSame( 'hp_price', $field['column'] );
$this->assertTrue( $field['indexed'] );
}
public function test_register_many_registers_all_fields(): void {
$this->registry->register_many( 'test', [
[ 'post_type' => 'hp_listing', 'meta_key' => 'hp_featured', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_featured' ],
[ 'post_type' => 'hp_listing', 'meta_key' => 'hp_verified', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_verified' ],
[ 'post_type' => 'hp_vendor', 'meta_key' => 'hp_verified', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_verified' ],
] );
$listing_hot = $this->registry->get_zone_fields_for_type( 'hot', 'hp_listing' );
$this->assertCount( 2, $listing_hot );
$vendor_hot = $this->registry->get_zone_fields_for_type( 'hot', 'hp_vendor' );
$this->assertCount( 1, $vendor_hot );
}
// ── get_field() ─────────────────────────────────────────────────────────
public function test_get_field_returns_null_for_unknown_key(): void {
$this->assertNull( $this->registry->get_field( 'hp_listing', 'hp_nonexistent' ) );
}
// ── get_field_zone() ────────────────────────────────────────────────────
public function test_get_field_zone_returns_correct_zone(): void {
$this->registry->register( 'test', [
'post_type' => 'hp_vendor',
'meta_key' => 'hp_description',
'zone' => 'cold',
'cache_group' => 'wpdo_cold',
'cache_ttl' => 3600,
] );
$zone = $this->registry->get_field_zone( 'hp_vendor', 'hp_description' );
$this->assertSame( 'cold', $zone );
}
public function test_get_field_zone_returns_null_for_unknown(): void {
$this->assertNull( $this->registry->get_field_zone( 'hp_listing', 'hp_missing' ) );
}
// ── get_hot_columns() ───────────────────────────────────────────────────
public function test_get_hot_columns_returns_column_to_data_type_map(): void {
$this->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',
] );
$cols = $this->registry->get_hot_columns( 'hp_listing' );
$this->assertArrayHasKey( 'hp_price', $cols );
$this->assertSame( 'decimal(10,2) NOT NULL DEFAULT 0', $cols['hp_price'] );
}
// ── Duplicate registration guard ────────────────────────────────────────
public function test_duplicate_registration_does_not_add_extra_entry(): void {
$field = [
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => 'hp_price',
];
$this->registry->register( 'test', $field );
$this->registry->register( 'test', $field );
$hot = $this->registry->get_zone_fields_for_type( 'hot', 'hp_listing' );
$this->assertCount( 1, $hot );
}
// ── get_stats() ─────────────────────────────────────────────────────────
public function test_get_stats_reflects_registered_fields(): void {
$this->registry->register_many( 'test', [
[ 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0', 'column' => 'hp_price' ],
[ 'post_type' => 'hp_listing', 'meta_key' => 'hp_featured', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_featured' ],
[ 'post_type' => 'hp_vendor', 'meta_key' => 'hp_desc', 'zone' => 'cold', 'cache_group' => 'g', 'cache_ttl' => 3600 ],
] );
$stats = $this->registry->get_stats();
$this->assertSame( 2, $stats['hot'] );
$this->assertSame( 1, $stats['cold'] );
$this->assertSame( 0, $stats['warm'] );
$this->assertSame( 0, $stats['archive'] );
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for the shadow_read_only sub-flag added by PR-4.
*
* Orthogonal to the main 7-state FSM. Only takes effect when a module is in
* the `verify` state.
*
* @covers WPDO_Feature_Flags::enable_shadow_read
* @covers WPDO_Feature_Flags::disable_shadow_read
* @covers WPDO_Feature_Flags::is_shadow_read_active
* @covers WPDO_Feature_Flags::all_shadow
*/
class ShadowReadFlagTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
// Reset both caches via reflection.
$ref = new ReflectionClass( WPDO_Feature_Flags::class );
foreach ( array( 'cache', 'shadow_cache' ) as $prop ) {
$p = $ref->getProperty( $prop );
$p->setAccessible( true );
$p->setValue( null, null );
}
}
public function test_default_is_inactive(): void {
$this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
}
public function test_enable_then_active_only_in_verify_state(): void {
WPDO_Feature_Flags::enable_shadow_read( 'hot_hp_listing' );
// idle → not active even though flag is on.
$this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
// dual_write → still not active.
WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' );
$this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
// verify → active.
WPDO_Feature_Flags::set( 'hot_hp_listing', 'verify' );
$this->assertTrue( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
// cutover → no longer active (verify-only sub-flag).
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
$this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
}
public function test_disable_clears_active(): void {
WPDO_Feature_Flags::enable_shadow_read( 'hot_hp_vendor' );
WPDO_Feature_Flags::set( 'hot_hp_vendor', 'verify' );
$this->assertTrue( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_vendor' ) );
WPDO_Feature_Flags::disable_shadow_read( 'hot_hp_vendor' );
$this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_vendor' ) );
}
public function test_all_shadow_returns_only_enabled_modules(): void {
WPDO_Feature_Flags::enable_shadow_read( 'mod_a' );
WPDO_Feature_Flags::enable_shadow_read( 'mod_b' );
WPDO_Feature_Flags::disable_shadow_read( 'mod_b' );
$flags = WPDO_Feature_Flags::all_shadow();
$this->assertArrayHasKey( 'mod_a', $flags );
$this->assertArrayNotHasKey( 'mod_b', $flags );
}
public function test_shadow_flag_independent_per_module(): void {
WPDO_Feature_Flags::enable_shadow_read( 'hot_hp_listing' );
WPDO_Feature_Flags::set( 'hot_hp_listing', 'verify' );
WPDO_Feature_Flags::set( 'hot_hp_vendor', 'verify' );
$this->assertTrue( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
$this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_vendor' ) );
}
}
@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
// Bootstrap missing WP filesystem helpers used by Snapshot_Manager (idempotent across tests).
if ( ! function_exists( 'wp_upload_dir' ) ) {
function wp_upload_dir(): array {
return array(
'basedir' => sys_get_temp_dir() . '/wpdo-test-uploads',
'baseurl' => 'http://localhost/uploads',
);
}
}
if ( ! function_exists( 'wp_mkdir_p' ) ) {
function wp_mkdir_p( string $dir ): bool {
if ( is_dir( $dir ) ) {
return true;
}
return mkdir( $dir, 0777, true );
}
}
if ( ! function_exists( 'esc_sql' ) ) {
function esc_sql( $s ): string {
return addslashes( (string) $s );
}
}
if ( ! function_exists( 'size_format' ) ) {
function size_format( int $bytes, int $decimals = 0 ): string {
return $bytes . 'B';
}
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-feature-flags.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-manager.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-writer.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-reader.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-pruner.php';
/**
* Unit tests for WPDO_Snapshot_Manager + Writer + Reader (v2.2.0 M1).
*
* These tests exercise pure logic + filesystem-isolated paths in sys_get_temp_dir().
* Heavy integration cases (real DB dump + restore) are covered by the
* integration suite and the e2e/wp-data-optimizer/ Playwright tests.
*/
class SnapshotManagerTest extends TestCase {
protected function setUp(): void {
// Clean tmp upload dir.
$dir = sys_get_temp_dir() . '/wpdo-test-uploads/wpdo-backups';
if ( is_dir( $dir ) ) {
foreach ( glob( $dir . '/*' ) as $f ) {
if ( is_file( $f ) ) {
@unlink( $f ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
}
}
}
}
public function test_generate_id_format_and_uniqueness(): void {
$ref = new ReflectionClass( WPDO_Snapshot_Manager::class );
$method = $ref->getMethod( 'generate_id' );
$method->setAccessible( true );
$ids = array();
for ( $i = 0; $i < 50; $i++ ) {
$id = $method->invoke( null );
$this->assertMatchesRegularExpression( '/^wpdo_[a-z0-9]+_[a-f0-9]+$/', $id );
$ids[] = $id;
}
$this->assertCount( 50, array_unique( $ids ), '50 generated IDs should all be distinct' );
}
public function test_ensure_backup_dir_creates_dir_and_htaccess(): void {
$ok = WPDO_Snapshot_Manager::ensure_backup_dir();
$this->assertTrue( $ok );
$dir = WPDO_Snapshot_Manager::backup_dir();
$this->assertDirectoryExists( $dir );
$this->assertFileExists( $dir . '/.htaccess' );
$this->assertStringContainsString( 'Deny from all', file_get_contents( $dir . '/.htaccess' ) );
$this->assertFileExists( $dir . '/index.php' );
}
public function test_create_with_invalid_trigger_returns_error(): void {
$result = WPDO_Snapshot_Manager::create( 'totally_made_up_trigger', array() );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'invalid_trigger', $result['error'] );
}
public function test_writer_escape_sql_value_handles_all_types(): void {
$w = new WPDO_Snapshot_Writer( 'wpdo_test_id', array() );
$ref = new ReflectionClass( WPDO_Snapshot_Writer::class );
$m = $ref->getMethod( 'escape_sql_value' );
$m->setAccessible( true );
$this->assertSame( 'NULL', $m->invoke( $w, null ) );
$this->assertSame( '0', $m->invoke( $w, false ) );
$this->assertSame( '1', $m->invoke( $w, true ) );
$this->assertSame( '42', $m->invoke( $w, 42 ) );
$this->assertSame( '3.14', $m->invoke( $w, 3.14 ) );
$this->assertSame( "'hello'", $m->invoke( $w, 'hello' ) );
$this->assertSame( "'don\\'t'", $m->invoke( $w, "don't" ) );
// Binary (non-utf8) should hex-encode.
$bin = "\x00\x01\xff\xfe";
$out = $m->invoke( $w, $bin );
$this->assertSame( '0x0001fffe', $out );
}
public function test_writer_is_safe_name_validates_table(): void {
global $wpdb;
$saved_prefix = $wpdb->prefix ?? 'wp_';
$wpdb->prefix = 'wp_';
$w = new WPDO_Snapshot_Writer( 'wpdo_test_id', array() );
$ref = new ReflectionClass( WPDO_Snapshot_Writer::class );
$m = $ref->getMethod( 'is_safe_name' );
$m->setAccessible( true );
$this->assertTrue( $m->invoke( $w, 'wp_postmeta' ) );
$this->assertTrue( $m->invoke( $w, 'wp_wpdo_warm' ) );
$this->assertFalse( $m->invoke( $w, 'foo_postmeta' ), 'wrong prefix should be rejected' );
$this->assertFalse( $m->invoke( $w, 'wp_post; DROP TABLE' ), 'sql injection should be rejected' );
$this->assertFalse( $m->invoke( $w, 'wp_post-bad' ), 'dash should be rejected' );
$wpdb->prefix = $saved_prefix;
}
public function test_reader_parse_summary_extracts_table_row_counts(): void {
$catalog_row = array(
'snapshot_id' => 'wpdo_dummy',
'storage' => 'inline',
'size_bytes' => 100,
'inline_blob' => '',
);
$reader = new WPDO_Snapshot_Reader( $catalog_row );
$ref = new ReflectionClass( WPDO_Snapshot_Reader::class );
$m = $ref->getMethod( 'parse_summary' );
$m->setAccessible( true );
$sql = "-- header
INSERT INTO `wp_wpdo_warm` (`a`,`b`) VALUES (1,'x'),
(2,'y'),
(3,'z');
INSERT INTO `wp_wpdo_archive` (`a`) VALUES (10);
";
$summary = $m->invoke( $reader, $sql );
$this->assertSame( 4, $summary['total_rows'] );
$this->assertSame( 2, $summary['statements'] );
$this->assertSame( 3, $summary['tables']['wp_wpdo_warm'] );
$this->assertSame( 1, $summary['tables']['wp_wpdo_archive'] );
}
public function test_reader_verify_inline_size_match(): void {
$blob = "INSERT INTO `wp_wpdo_warm` VALUES (1);\n";
$reader = new WPDO_Snapshot_Reader( array(
'snapshot_id' => 'wpdo_dummy',
'storage' => 'inline',
'size_bytes' => strlen( $blob ),
'inline_blob' => $blob,
) );
$result = $reader->verify();
$this->assertTrue( $result['ok'] );
$this->assertTrue( $result['size_match'] );
$this->assertSame( 'inline', $result['storage'] );
}
public function test_reader_verify_inline_size_mismatch(): void {
$blob = "AAA";
$reader = new WPDO_Snapshot_Reader( array(
'snapshot_id' => 'wpdo_dummy',
'storage' => 'inline',
'size_bytes' => 999, // claim size that doesn't match.
'inline_blob' => $blob,
) );
$result = $reader->verify();
$this->assertFalse( $result['ok'] );
$this->assertFalse( $result['size_match'] );
}
public function test_reader_verify_file_missing(): void {
$reader = new WPDO_Snapshot_Reader( array(
'snapshot_id' => 'wpdo_dummy',
'storage' => 'file',
'size_bytes' => 100,
'file_path' => '/non/existent/path.sql.gz',
'file_sha256' => str_repeat( '0', 64 ),
) );
$result = $reader->verify();
$this->assertFalse( $result['ok'] );
$this->assertSame( 'file_missing', $result['error'] );
}
public function test_reader_maybe_gunzip_decompresses_real_gzip(): void {
$plaintext = "INSERT INTO `wp_wpdo_warm` VALUES (1);\n";
$gzipped = gzencode( $plaintext );
$catalog_row = array(
'snapshot_id' => 'wpdo_dummy',
'storage' => 'inline',
'size_bytes' => strlen( $gzipped ),
'inline_blob' => $gzipped,
);
$reader = new WPDO_Snapshot_Reader( $catalog_row );
$ref = new ReflectionClass( WPDO_Snapshot_Reader::class );
$m = $ref->getMethod( 'maybe_gunzip' );
$m->setAccessible( true );
$out = $m->invoke( $reader, $gzipped );
$this->assertSame( $plaintext, $out );
// Plain text should pass through untouched.
$out_plain = $m->invoke( $reader, $plaintext );
$this->assertSame( $plaintext, $out_plain );
}
public function test_reader_load_sql_inline_decompresses(): void {
$plaintext = "INSERT INTO `wp_test` VALUES (1);\n";
$gzipped = gzencode( $plaintext );
$reader = new WPDO_Snapshot_Reader( array(
'snapshot_id' => 'wpdo_dummy',
'storage' => 'inline',
'size_bytes' => strlen( $gzipped ),
'inline_blob' => $gzipped,
) );
$ref = new ReflectionClass( WPDO_Snapshot_Reader::class );
$m = $ref->getMethod( 'load_sql' );
$m->setAccessible( true );
$out = $m->invoke( $reader );
$this->assertSame( $plaintext, $out );
}
public function test_reader_throws_on_missing_required_keys(): void {
$this->expectException( InvalidArgumentException::class );
new WPDO_Snapshot_Reader( array() );
}
public function test_pruner_protected_triggers_listed(): void {
$ref = new ReflectionClass( WPDO_Snapshot_Pruner::class );
$prop = $ref->getReflectionConstant( 'PROTECTED_TRIGGERS' );
$this->assertNotNull( $prop );
$value = $prop->getValue();
$this->assertContains( 'pre_uninstall', $value );
$this->assertContains( 'pre_v2_upgrade', $value );
}
public function test_manager_valid_triggers_constant(): void {
$this->assertContains( 'manual', WPDO_Snapshot_Manager::VALID_TRIGGERS );
$this->assertContains( 'pre_fsm_transition', WPDO_Snapshot_Manager::VALID_TRIGGERS );
$this->assertContains( 'pre_v2_upgrade', WPDO_Snapshot_Manager::VALID_TRIGGERS );
$this->assertContains( 'scheduled', WPDO_Snapshot_Manager::VALID_TRIGGERS );
$this->assertContains( 'pre_uninstall', WPDO_Snapshot_Manager::VALID_TRIGGERS );
}
}
+212
View File
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Sync_Bridge — zone-aware dual-write dispatcher.
*
* Tests early-return guard conditions, write routing, and post cleanup.
*/
class SyncBridgeTest extends TestCase {
private WPDO_Sync_Bridge $bridge;
/** Capture last SQL passed to $wpdb->query(). */
public static string $last_query = '';
/** Configurable return value for $wpdb->get_var(). */
public static ?string $get_var_return = null;
protected function setUp(): void {
$this->bridge = new WPDO_Sync_Bridge();
// Reset globals.
$GLOBALS['_wp_options'] = [];
$GLOBALS['_wp_post_types'] = [];
self::$last_query = '';
self::$get_var_return = null;
// Reset private statics via reflection.
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
$ref->getProperty( 'bypassing' )->setValue( null, false );
$ref->getProperty( 'field_cache' )->setValue( null, [] );
// Reset Schema Registry singleton.
$sr = new ReflectionClass( WPDO_Schema_Registry::class );
$sr->getProperty( 'instance' )->setValue( null, null );
// Reset Feature Flags request cache.
$ff = new ReflectionClass( WPDO_Feature_Flags::class );
$ff->getProperty( 'cache' )->setValue( null, null );
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public string $postmeta = 'wp_postmeta';
public string $posts = 'wp_posts';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
public function get_var( string $sql ): ?string {
return SyncBridgeTest::$get_var_return;
}
public function get_row( string $sql, $output = OBJECT ) { return null; }
public function get_results( string $sql, $output = OBJECT ): array { return []; }
public function insert( string $table, array $data, $format = null ): int|false { return 1; }
public function update( string $table, array $data, array $where, $f = null, $wf = null ): int|false { return 1; }
public function delete( string $table, array $where, $format = null ): int|false { return 1; }
public function query( string $sql ): int|bool {
SyncBridgeTest::$last_query = $sql;
return 1;
}
};
}
// ── Helper: register a hot field ─────────────────────────────────────────
private function register_hot_field( string $post_type = 'hp_listing', string $meta_key = 'hp_price' ): void {
WPDO_Schema_Registry::instance()->register( 'test', [
'post_type' => $post_type,
'meta_key' => $meta_key,
'zone' => 'hot',
'column' => $meta_key,
'type' => 'decimal',
] );
}
// ── intercept_get: early-return guards ───────────────────────────────────
public function test_intercept_get_returns_null_when_bypassing(): void {
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
$ref->getProperty( 'bypassing' )->setValue( null, true );
$result = $this->bridge->intercept_get( null, 1, 'hp_price', true );
$this->assertNull( $result );
}
public function test_intercept_get_returns_null_for_zero_post_id(): void {
$result = $this->bridge->intercept_get( null, 0, 'hp_price', true );
$this->assertNull( $result );
}
public function test_intercept_get_returns_null_for_empty_meta_key(): void {
$result = $this->bridge->intercept_get( null, 1, '', true );
$this->assertNull( $result );
}
public function test_intercept_get_returns_null_when_post_type_unknown(): void {
// Post ID 99 not in _wp_post_types — get_post_type returns false.
$result = $this->bridge->intercept_get( null, 99, 'hp_price', true );
$this->assertNull( $result );
}
public function test_intercept_get_returns_null_when_field_not_registered(): void {
$GLOBALS['_wp_post_types'][1] = 'hp_listing';
// No field registered → returns unchanged $value.
$result = $this->bridge->intercept_get( null, 1, 'unregistered_key', true );
$this->assertNull( $result );
}
public function test_intercept_get_returns_null_when_module_not_cutover(): void {
$GLOBALS['_wp_post_types'][1] = 'hp_listing';
$this->register_hot_field();
// Module stays idle (not set) → is_read_custom returns false.
$result = $this->bridge->intercept_get( null, 1, 'hp_price', true );
$this->assertNull( $result );
}
public function test_intercept_get_returns_zone_value_when_cutover(): void {
$GLOBALS['_wp_post_types'][2] = 'hp_listing';
$this->register_hot_field();
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
self::$get_var_return = '42';
$result = $this->bridge->intercept_get( null, 2, 'hp_price', true );
// Returns array-wrapped value (WordPress unwraps on $single=true).
$this->assertSame( [ '42' ], $result );
}
public function test_intercept_get_returns_null_when_zone_returns_null(): void {
$GLOBALS['_wp_post_types'][3] = 'hp_listing';
$this->register_hot_field();
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
self::$get_var_return = null; // Zone returns nothing.
$result = $this->bridge->intercept_get( null, 3, 'hp_price', true );
$this->assertNull( $result );
}
// ── intercept_update ─────────────────────────────────────────────────────
public function test_intercept_update_skips_when_bypassing(): void {
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
$ref->getProperty( 'bypassing' )->setValue( null, true );
$result = $this->bridge->intercept_update( null, 1, 'hp_price', '99', '' );
$this->assertNull( $result );
$this->assertEmpty( self::$last_query );
}
public function test_intercept_update_passes_through_when_no_field_registered(): void {
$GLOBALS['_wp_post_types'][1] = 'hp_listing';
// No field registered → returns $check unchanged.
$result = $this->bridge->intercept_update( null, 1, 'hp_price', '99', '' );
$this->assertNull( $result );
}
public function test_intercept_update_writes_to_zone_when_write_active(): void {
$GLOBALS['_wp_post_types'][5] = 'hp_listing';
$this->register_hot_field();
WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' );
$this->bridge->intercept_update( null, 5, 'hp_price', '150', '' );
// Zone Hot set() executes an UPSERT query.
$this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', self::$last_query );
}
// ── intercept_add ────────────────────────────────────────────────────────
public function test_intercept_add_writes_when_module_write_active(): void {
$GLOBALS['_wp_post_types'][6] = 'hp_listing';
$this->register_hot_field();
WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' );
$this->bridge->intercept_add( null, 6, 'hp_price', '200', false );
$this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', self::$last_query );
}
// ── cleanup_post ─────────────────────────────────────────────────────────
public function test_cleanup_post_does_nothing_for_unknown_post_type(): void {
// Post ID 999 has no type → early return.
$this->bridge->cleanup_post( 999 );
$this->assertEmpty( self::$last_query );
}
public function test_cleanup_post_deletes_hot_zone_data(): void {
$GLOBALS['_wp_post_types'][10] = 'hp_listing';
$this->register_hot_field();
$this->bridge->cleanup_post( 10 );
// WPDO_Zone_Hot::delete() calls $wpdb->delete() — but our mock captures query().
// The hot delete uses $wpdb->delete(), not query(). Just assert no exception thrown.
$this->assertTrue( true );
}
}
+302
View File
@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Zone_Archive — gzip-compressed historical archival.
*
* Uses an in-memory store to intercept $wpdb calls.
*/
class ZoneArchiveTest extends TestCase {
/** In-memory archive rows captured from $wpdb->insert() calls. */
public static array $store = [];
/** Arguments of the last $wpdb->delete() call. */
public static array $last_delete = [];
protected function setUp(): void {
self::$store = [];
self::$last_delete = [];
$GLOBALS['_wp_postmeta'] = [];
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
public function insert( string $table, array $data, $format = null ): int|false {
ZoneArchiveTest::$store[] = $data;
return 1;
}
public function delete( string $table, array $where, $format = null ): int|false {
ZoneArchiveTest::$last_delete = [ 'table' => $table, 'where' => $where ];
// Remove matching rows (single-column where only).
ZoneArchiveTest::$store = array_values( array_filter(
ZoneArchiveTest::$store,
static function ( array $row ) use ( $where ): bool {
foreach ( $where as $col => $val ) {
if ( isset( $row[ $col ] ) && (string) $row[ $col ] === (string) $val ) {
return false; // row matches → remove.
}
}
return true;
}
) );
return 1;
}
public function get_results( string $sql, $output = OBJECT ): array {
$flat = preg_replace( '/\s+/', ' ', $sql );
// stats() GROUP BY post_type query.
if ( stripos( $flat, 'GROUP BY post_type' ) !== false ) {
$by_type = [];
foreach ( ZoneArchiveTest::$store as $row ) {
$pt = $row['post_type'] ?? 'unknown';
$by_type[ $pt ] = ( $by_type[ $pt ] ?? 0 ) + 1;
}
$result = [];
foreach ( $by_type as $pt => $cnt ) {
$result[] = [ 'post_type' => $pt, 'cnt' => (string) $cnt ];
}
return $result;
}
// get() queries — filter by post_id and optional meta_key.
$post_id = null;
$meta_key = null;
if ( preg_match( '/post_id = (\d+)/', $flat, $m ) ) {
$post_id = (int) $m[1];
}
if ( preg_match( "/AND meta_key = '([^']+)'/", $flat, $m ) ) {
$meta_key = $m[1];
}
$result = [];
foreach ( ZoneArchiveTest::$store as $row ) {
if ( $post_id !== null && (int) ( $row['post_id'] ?? 0 ) !== $post_id ) {
continue;
}
if ( $meta_key !== null && ( $row['meta_key'] ?? '' ) !== $meta_key ) {
continue;
}
$result[] = [
'meta_key' => $row['meta_key'] ?? '',
'meta_value' => $row['meta_value'] ?? '',
'compressed' => $row['compressed'] ?? 0,
'archived_at' => $row['archived_at'] ?? '',
];
}
return $result;
}
public function get_var( string $sql ): ?string {
$flat = preg_replace( '/\s+/', ' ', $sql );
if ( stripos( $flat, 'WHERE compressed = 1' ) !== false ) {
$count = count( array_filter(
ZoneArchiveTest::$store,
static fn( array $r ) => (int) ( $r['compressed'] ?? 0 ) === 1
) );
return (string) $count;
}
if ( stripos( $flat, 'COUNT(*)' ) !== false ) {
return (string) count( ZoneArchiveTest::$store );
}
return null;
}
public function query( string $sql ): int|bool {
return 1; // BEGIN, COMMIT, ROLLBACK pass-through.
}
};
}
// ── table() ──────────────────────────────────────────────────────────────
public function test_table_returns_archive_table_name(): void {
$this->assertSame( 'wp_wpdo_archive', WPDO_Zone_Archive::table() );
}
// ── archive() ────────────────────────────────────────────────────────────
public function test_archive_inserts_row_with_correct_fields(): void {
WPDO_Zone_Archive::archive( 1, 'hp_listing', 'hp_price', '99.99' );
$this->assertCount( 1, self::$store );
$row = self::$store[0];
$this->assertSame( 1, $row['post_id'] );
$this->assertSame( 'hp_listing', $row['post_type'] );
$this->assertSame( 'hp_price', $row['meta_key'] );
$this->assertSame( '99.99', $row['meta_value'] );
}
public function test_archive_without_compress_keeps_plaintext_value(): void {
WPDO_Zone_Archive::archive( 2, 'hp_listing', 'hp_price', 'plain_value', 0, false );
$this->assertSame( 'plain_value', self::$store[0]['meta_value'] );
$this->assertSame( 0, self::$store[0]['compressed'] );
}
public function test_archive_with_compress_sets_compressed_flag(): void {
WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_price', 'compress_me', 0, true );
$this->assertSame( 1, self::$store[0]['compressed'] );
}
public function test_archive_with_compress_stores_base64_encoded_gzip(): void {
$original = 'hello compressed world';
WPDO_Zone_Archive::archive( 4, 'hp_listing', 'hp_bio', $original, 0, true );
$stored = self::$store[0]['meta_value'];
$decoded = base64_decode( $stored, true );
$restored = gzdecode( $decoded );
$this->assertSame( $original, $restored );
}
public function test_archive_stores_original_meta_id(): void {
WPDO_Zone_Archive::archive( 5, 'hp_listing', 'hp_price', '10', 42 );
$this->assertSame( 42, self::$store[0]['original_meta_id'] );
}
// ── archive_batch() ──────────────────────────────────────────────────────
public function test_archive_batch_inserts_all_entries(): void {
WPDO_Zone_Archive::archive_batch( [
[ 'post_id' => 10, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '100', 'meta_id' => 0 ],
[ 'post_id' => 11, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '200', 'meta_id' => 0 ],
[ 'post_id' => 12, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '300', 'meta_id' => 0 ],
] );
$this->assertCount( 3, self::$store );
}
public function test_archive_batch_with_empty_entries_is_safe(): void {
WPDO_Zone_Archive::archive_batch( [] );
$this->assertCount( 0, self::$store );
}
// ── get() ─────────────────────────────────────────────────────────────────
public function test_get_returns_empty_array_for_missing_post(): void {
$result = WPDO_Zone_Archive::get( 999 );
$this->assertSame( [], $result );
}
public function test_get_returns_all_entries_for_post(): void {
WPDO_Zone_Archive::archive( 20, 'hp_listing', 'hp_price', '50' );
WPDO_Zone_Archive::archive( 20, 'hp_listing', 'hp_category', '3' );
$result = WPDO_Zone_Archive::get( 20 );
$this->assertCount( 2, $result );
}
public function test_get_with_meta_key_filter_returns_only_matching(): void {
WPDO_Zone_Archive::archive( 21, 'hp_listing', 'hp_price', '150' );
WPDO_Zone_Archive::archive( 21, 'hp_listing', 'hp_category', '2' );
$result = WPDO_Zone_Archive::get( 21, 'hp_price' );
$this->assertCount( 1, $result );
$this->assertSame( 'hp_price', $result[0]['meta_key'] );
}
public function test_get_decompresses_gzipped_values(): void {
$original = 'hello decompressed world';
WPDO_Zone_Archive::archive( 22, 'hp_listing', 'hp_bio', $original, 0, true );
$result = WPDO_Zone_Archive::get( 22 );
$this->assertCount( 1, $result );
$this->assertSame( $original, $result[0]['meta_value'] );
}
public function test_get_removes_compressed_field_from_result(): void {
WPDO_Zone_Archive::archive( 23, 'hp_listing', 'hp_price', '10' );
$result = WPDO_Zone_Archive::get( 23 );
$this->assertArrayNotHasKey( 'compressed', $result[0] );
}
// ── restore() ────────────────────────────────────────────────────────────
public function test_restore_writes_to_post_meta(): void {
WPDO_Zone_Archive::archive( 30, 'hp_listing', 'hp_price', '77' );
WPDO_Zone_Archive::archive( 30, 'hp_listing', 'hp_category', '5' );
WPDO_Zone_Archive::restore( 30 );
$this->assertSame( '77', $GLOBALS['_wp_postmeta'][30]['hp_price'] );
$this->assertSame( '5', $GLOBALS['_wp_postmeta'][30]['hp_category'] );
}
public function test_restore_returns_correct_entry_count(): void {
WPDO_Zone_Archive::archive( 31, 'hp_listing', 'hp_price', '88' );
WPDO_Zone_Archive::archive( 31, 'hp_listing', 'hp_category', '9' );
$count = WPDO_Zone_Archive::restore( 31 );
$this->assertSame( 2, $count );
}
public function test_restore_returns_zero_for_missing_post(): void {
$count = WPDO_Zone_Archive::restore( 999 );
$this->assertSame( 0, $count );
}
// ── delete() ─────────────────────────────────────────────────────────────
public function test_delete_passes_correct_post_id_to_wpdb(): void {
WPDO_Zone_Archive::archive( 40, 'hp_listing', 'hp_price', '100' );
WPDO_Zone_Archive::delete( 40 );
$this->assertNotEmpty( self::$last_delete );
$this->assertSame( 40, self::$last_delete['where']['post_id'] );
}
// ── stats() ──────────────────────────────────────────────────────────────
public function test_stats_includes_required_keys(): void {
$stats = WPDO_Zone_Archive::stats();
$this->assertArrayHasKey( 'total_rows', $stats );
$this->assertArrayHasKey( 'compressed_rows', $stats );
$this->assertArrayHasKey( 'post_types', $stats );
}
public function test_stats_total_and_compressed_counts(): void {
WPDO_Zone_Archive::archive( 50, 'hp_listing', 'hp_price', '1', 0, true );
WPDO_Zone_Archive::archive( 51, 'hp_listing', 'hp_price', '2', 0, false );
WPDO_Zone_Archive::archive( 52, 'hp_listing', 'hp_price', '3', 0, true );
$stats = WPDO_Zone_Archive::stats();
$this->assertSame( 3, $stats['total_rows'] );
$this->assertSame( 2, $stats['compressed_rows'] );
}
public function test_stats_post_types_groups_by_type(): void {
WPDO_Zone_Archive::archive( 60, 'hp_listing', 'hp_price', '1' );
WPDO_Zone_Archive::archive( 61, 'hp_listing', 'hp_price', '2' );
WPDO_Zone_Archive::archive( 62, 'hp_vendor', 'hp_bio', 'x' );
$stats = WPDO_Zone_Archive::stats();
$by_type = array_column( $stats['post_types'], 'cnt', 'post_type' );
$this->assertSame( '2', $by_type['hp_listing'] );
$this->assertSame( '1', $by_type['hp_vendor'] );
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Zone_Classifier — zone suggestion engine.
*
* Tests private static methods via PHP Reflection.
*/
class ZoneClassifierTest extends TestCase {
/**
* Invoke a private static method on WPDO_Zone_Classifier via reflection.
*/
private function invoke_private( string $method, array $args ): mixed {
$ref = new ReflectionClass( WPDO_Zone_Classifier::class );
$m = $ref->getMethod( $method );
$m->setAccessible( true );
return $m->invokeArgs( null, $args );
}
// ── is_wp_internal() ─────────────────────────────────────────────────────
public function test_is_wp_internal_returns_true_for_edit_lock(): void {
$result = $this->invoke_private( 'is_wp_internal', [ '_edit_lock' ] );
$this->assertTrue( $result );
}
public function test_is_wp_internal_returns_true_for_thumbnail_id(): void {
$result = $this->invoke_private( 'is_wp_internal', [ '_thumbnail_id' ] );
$this->assertTrue( $result );
}
public function test_is_wp_internal_returns_false_for_hp_price(): void {
$result = $this->invoke_private( 'is_wp_internal', [ 'hp_price' ] );
$this->assertFalse( $result );
}
public function test_is_wp_internal_returns_false_for_custom_key(): void {
$result = $this->invoke_private( 'is_wp_internal', [ 'my_custom_meta' ] );
$this->assertFalse( $result );
}
// ── score_zones() — hot ──────────────────────────────────────────────────
public function test_score_zones_hot_for_numeric_short_values(): void {
$signals = $this->make_signals( [
'avg_length' => 10,
'numeric_ratio' => 0.9,
'distinct_values' => 5,
] );
$scores = $this->invoke_private( 'score_zones', [ $signals ] );
// Hot should outrank cold and archive.
$this->assertGreaterThan( $scores['cold'], $scores['hot'] );
$this->assertGreaterThan( $scores['archive'], $scores['hot'] );
}
// ── score_zones() — warm ─────────────────────────────────────────────────
public function test_score_zones_warm_for_transient_prefix(): void {
$signals = $this->make_signals( [ 'prefix' => 'transient' ] );
$scores = $this->invoke_private( 'score_zones', [ $signals ] );
$this->assertGreaterThanOrEqual( 0.8, $scores['warm'] );
}
// ── score_zones() — cold ─────────────────────────────────────────────────
public function test_score_zones_cold_for_long_json_values(): void {
$signals = $this->make_signals( [
'avg_length' => 300,
'is_json' => true,
] );
$scores = $this->invoke_private( 'score_zones', [ $signals ] );
$this->assertGreaterThan( $scores['hot'], $scores['cold'] );
$this->assertGreaterThan( $scores['archive'], $scores['cold'] );
}
// ── score_zones() — archive ──────────────────────────────────────────────
public function test_score_zones_archive_for_high_trash_ratio(): void {
$signals = $this->make_signals( [ 'trash_ratio' => 0.7 ] );
$scores = $this->invoke_private( 'score_zones', [ $signals ] );
$this->assertGreaterThanOrEqual( 0.6, $scores['archive'] );
}
// ── score_zones() — default ───────────────────────────────────────────────
public function test_score_zones_defaults_cold_when_no_signals(): void {
$signals = $this->make_signals( [] );
$scores = $this->invoke_private( 'score_zones', [ $signals ] );
// With avg_length=0, numeric_ratio=0, etc., hot gets 0.3 (avg_length<50 is true for 0).
// Cold must have at least a non-zero score (either from scoring or the fallback).
$max = max( $scores );
$this->assertGreaterThan( 0.0, $max );
$this->assertGreaterThan( 0.0, $scores['cold'] + $scores['hot'] ); // At least one has a score.
}
// ── build_reasons() ──────────────────────────────────────────────────────
public function test_build_reasons_hot_includes_numeric_message(): void {
$signals = $this->make_signals( [
'avg_length' => 10,
'numeric_ratio' => 0.9,
'distinct_values' => 5,
] );
$reasons = $this->invoke_private( 'build_reasons', [ $signals, 'hot' ] );
$combined = implode( ' ', $reasons );
$this->assertStringContainsStringIgnoringCase( 'numeric', $combined );
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Build a signals array with defaults, overriding specific keys.
*/
private function make_signals( array $overrides ): array {
return array_merge( [
'meta_key' => 'test_key',
'row_count' => 100,
'avg_length' => 0,
'max_length' => 0,
'distinct_values' => 0,
'numeric_ratio' => 0.0,
'trash_ratio' => 0.0,
'is_serialized' => false,
'is_json' => false,
'prefix' => '',
], $overrides );
}
}
+229
View File
@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Zone_Cold — JSON blob storage with Object Cache integration.
*/
class ZoneColdTest extends TestCase {
/** In-memory "database" store: post_id => json string */
public static array $db_store = [];
/** Control whether get_var returns the "id" existence check */
public static bool $row_exists = false;
protected function setUp(): void {
self::$db_store = [];
self::$row_exists = false;
$GLOBALS['_wp_cache'] = [];
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
/**
* get_var is used for two things:
* 1. SELECT data ... → return JSON blob
* 2. SELECT id ... → return '1' if exists, else null
*/
public function get_var( string $sql ): ?string {
$flat = preg_replace( '/\s+/', ' ', $sql );
// Existence check (save_blob path).
if ( stripos( $flat, 'SELECT id' ) !== false ) {
if ( preg_match( "/post_id = (\d+)/", $flat, $m ) ) {
return isset( ZoneColdTest::$db_store[ (int) $m[1] ] ) ? '1' : null;
}
return null;
}
// Data fetch.
if ( preg_match( "/post_id = (\d+)/", $flat, $m ) ) {
return ZoneColdTest::$db_store[ (int) $m[1] ] ?? null;
}
return null;
}
public function get_row( string $sql, $output = OBJECT ) {
return null;
}
public function get_results( string $sql, $output = OBJECT ): array {
return [];
}
public function insert( string $table, array $data, $format = null ): int|false {
if ( isset( $data['post_id'], $data['data'] ) ) {
ZoneColdTest::$db_store[ (int) $data['post_id'] ] = $data['data'];
}
return 1;
}
public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false {
if ( isset( $where['post_id'], $data['data'] ) ) {
ZoneColdTest::$db_store[ (int) $where['post_id'] ] = $data['data'];
}
return 1;
}
public function delete( string $table, array $where, $format = null ): int|false {
if ( isset( $where['post_id'] ) ) {
unset( ZoneColdTest::$db_store[ (int) $where['post_id'] ] );
}
return 1;
}
public function query( string $sql ): int|bool {
return 1;
}
};
}
// ── table() ──────────────────────────────────────────────────────────────
public function test_table_returns_prefixed_name(): void {
$this->assertSame( 'wp_wpdo_cold_hp_listing', WPDO_Zone_Cold::table( 'hp_listing' ) );
}
// ── get() ─────────────────────────────────────────────────────────────────
public function test_get_returns_null_for_missing_key(): void {
// Cache miss + no DB row → blob is empty array → key missing → null.
$result = WPDO_Zone_Cold::get( 99, 'hp_listing', 'hp_description' );
$this->assertNull( $result );
}
public function test_get_reads_from_cache_on_hit(): void {
// Pre-populate cache so DB should NOT be hit.
$group = 'wpdo_cold_hp_listing';
$cache_key = 'cold_1';
$GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'hp_bio' => 'cached value' ];
$result = WPDO_Zone_Cold::get( 1, 'hp_listing', 'hp_bio' );
$this->assertSame( 'cached value', $result );
// DB store should remain empty (DB was not queried for data).
$this->assertEmpty( self::$db_store );
}
// ── get_blob() ───────────────────────────────────────────────────────────
public function test_get_blob_queries_db_on_cache_miss(): void {
self::$db_store[5] = json_encode( [ 'hp_description' => 'Hello World', 'hp_location' => 'Paris' ] );
$blob = WPDO_Zone_Cold::get_blob( 5, 'hp_listing' );
$this->assertSame( 'Hello World', $blob['hp_description'] );
$this->assertSame( 'Paris', $blob['hp_location'] );
}
// ── set() ─────────────────────────────────────────────────────────────────
public function test_set_merges_new_key_into_blob(): void {
// Seed an existing blob.
self::$db_store[10] = json_encode( [ 'a' => 1 ] );
WPDO_Zone_Cold::set( 10, 'hp_listing', 'b', 2 );
$stored = json_decode( self::$db_store[10], true );
$this->assertArrayHasKey( 'a', $stored );
$this->assertArrayHasKey( 'b', $stored );
$this->assertSame( 1, $stored['a'] );
$this->assertSame( 2, $stored['b'] );
}
// ── set_many() ───────────────────────────────────────────────────────────
public function test_set_many_merges_multiple_keys(): void {
WPDO_Zone_Cold::set_many( 20, 'hp_listing', [
'key1' => 'v1',
'key2' => 'v2',
'key3' => 'v3',
] );
$stored = json_decode( self::$db_store[20], true );
$this->assertSame( 'v1', $stored['key1'] );
$this->assertSame( 'v2', $stored['key2'] );
$this->assertSame( 'v3', $stored['key3'] );
}
// ── remove() ─────────────────────────────────────────────────────────────
public function test_remove_deletes_key_from_blob(): void {
self::$db_store[30] = json_encode( [ 'keep' => 'yes', 'drop' => 'no' ] );
WPDO_Zone_Cold::remove( 30, 'hp_listing', 'drop' );
$stored = json_decode( self::$db_store[30], true );
$this->assertArrayHasKey( 'keep', $stored );
$this->assertArrayNotHasKey( 'drop', $stored );
}
// ── delete() ─────────────────────────────────────────────────────────────
public function test_delete_clears_cache(): void {
$group = 'wpdo_cold_hp_listing';
$cache_key = 'cold_1';
// Pre-populate cache.
$GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'some' => 'data' ];
WPDO_Zone_Cold::delete( 1, 'hp_listing' );
// Cache entry must be gone.
$this->assertFalse( isset( $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ) );
}
// ── Additional edge-case tests ────────────────────────────────────────────
public function test_set_invalidates_object_cache(): void {
$group = 'wpdo_cold_hp_listing';
$cache_key = 'cold_50';
// Pre-populate cache with stale data.
$GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'stale' => 'old_value' ];
WPDO_Zone_Cold::set( 50, 'hp_listing', 'fresh', 'new_value' );
// Cache must be invalidated after write.
$this->assertFalse( isset( $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ) );
}
public function test_get_blob_populates_cache_on_db_hit(): void {
// Seed the DB store so get_blob has something to fetch.
self::$db_store[60] = json_encode( [ 'cached_key' => 'cached_val' ] );
WPDO_Zone_Cold::get_blob( 60, 'hp_listing' );
// Cache must now contain the fetched data.
$group = 'wpdo_cold_hp_listing';
$cached = $GLOBALS['_wp_cache'][ $group ]['cold_60'] ?? false;
$this->assertIsArray( $cached );
$this->assertSame( 'cached_val', $cached['cached_key'] );
}
public function test_remove_nonexistent_key_is_safe(): void {
// Store an existing blob.
self::$db_store[70] = json_encode( [ 'keep' => 'this' ] );
// Remove a key that doesn't exist — should not throw.
WPDO_Zone_Cold::remove( 70, 'hp_listing', 'nonexistent_key' );
$stored = json_decode( self::$db_store[70], true );
$this->assertArrayHasKey( 'keep', $stored );
$this->assertArrayNotHasKey( 'nonexistent_key', $stored );
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Zone_Hot — flat-column custom tables for search/filter fields.
*
* Uses an in-memory mock to intercept $wpdb calls.
*/
class ZoneHotTest extends TestCase {
/** Last SQL query string passed to $wpdb->query(). */
public static string $last_query = '';
/** Arguments passed to $wpdb->delete(). */
public static array $last_delete = [];
/** Return value for get_var mock. */
public static ?string $get_var_return = null;
/** Return value for get_row mock. */
public static mixed $get_row_return = null;
protected function setUp(): void {
self::$last_query = '';
self::$last_delete = [];
self::$get_var_return = null;
self::$get_row_return = null;
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
public function get_var( string $sql ): ?string {
return ZoneHotTest::$get_var_return;
}
public function get_row( string $sql, $output = OBJECT ) {
return ZoneHotTest::$get_row_return;
}
public function get_results( string $sql, $output = OBJECT ): array {
return [];
}
public function insert( string $table, array $data, $format = null ): int|false {
return 1;
}
public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false {
return 1;
}
public function delete( string $table, array $where, $format = null ): int|false {
ZoneHotTest::$last_delete = [ 'table' => $table, 'where' => $where ];
return 1;
}
public function query( string $sql ): int|bool {
ZoneHotTest::$last_query = $sql;
return 1;
}
};
}
// ── table() ──────────────────────────────────────────────────────────────
public function test_table_returns_prefixed_name(): void {
$this->assertSame( 'wp_wpdo_hot_hp_listing', WPDO_Zone_Hot::table( 'hp_listing' ) );
}
public function test_table_sanitizes_post_type(): void {
// The test sanitize_key stub strips non-[a-z0-9_-] chars then lowercases.
// 'HP Listing!' → strip uppercase H,P + space + '!' → 'isting' → lower → 'isting'.
$sanitized = sanitize_key( 'HP Listing!' );
$this->assertSame( 'wp_wpdo_hot_' . $sanitized, WPDO_Zone_Hot::table( 'HP Listing!' ) );
}
// ── get() ─────────────────────────────────────────────────────────────────
public function test_get_returns_null_when_row_missing(): void {
self::$get_var_return = null;
$result = WPDO_Zone_Hot::get( 1, 'hp_listing', 'hp_price' );
$this->assertNull( $result );
}
public function test_get_returns_value_from_db(): void {
self::$get_var_return = '42';
$result = WPDO_Zone_Hot::get( 1, 'hp_listing', 'hp_price' );
$this->assertSame( '42', $result );
}
// ── get_row() ────────────────────────────────────────────────────────────
public function test_get_row_returns_null_when_missing(): void {
self::$get_row_return = null;
$result = WPDO_Zone_Hot::get_row( 1, 'hp_listing' );
$this->assertNull( $result );
}
public function test_get_row_returns_array(): void {
$expected = [ 'post_id' => 1, 'hp_price' => '100', 'hp_location' => 'NYC' ];
self::$get_row_return = $expected;
$result = WPDO_Zone_Hot::get_row( 1, 'hp_listing' );
$this->assertSame( $expected, $result );
}
// ── set() ─────────────────────────────────────────────────────────────────
public function test_set_executes_upsert_query(): void {
WPDO_Zone_Hot::set( 5, 'hp_listing', 'hp_price', '99' );
$this->assertNotEmpty( self::$last_query, 'Expected $wpdb->query() to be called' );
$this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', self::$last_query );
}
// ── set_many() ───────────────────────────────────────────────────────────
public function test_set_many_includes_all_columns_in_upsert(): void {
WPDO_Zone_Hot::set_many( 7, 'hp_listing', [
'hp_price' => '150',
'hp_location' => 'LA',
'hp_category' => '3',
] );
$this->assertNotEmpty( self::$last_query );
$this->assertStringContainsString( 'hp_price', self::$last_query );
$this->assertStringContainsString( 'hp_location', self::$last_query );
$this->assertStringContainsString( 'hp_category', self::$last_query );
}
// ── delete() ─────────────────────────────────────────────────────────────
public function test_delete_calls_wpdb_delete(): void {
WPDO_Zone_Hot::delete( 42, 'hp_listing' );
$this->assertNotEmpty( self::$last_delete, 'Expected $wpdb->delete() to be called' );
$this->assertSame( 42, self::$last_delete['where']['post_id'] );
}
public function test_delete_table_name_contains_post_type(): void {
WPDO_Zone_Hot::delete( 5, 'hp_vendor' );
$this->assertStringContainsString( 'hp_vendor', self::$last_delete['table'] );
}
// ── Additional edge cases ─────────────────────────────────────────────────
public function test_table_for_different_post_type(): void {
$this->assertSame( 'wp_wpdo_hot_hp_vendor', WPDO_Zone_Hot::table( 'hp_vendor' ) );
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Zone_Warm — Zone B KV table with TTL.
*
* Uses an in-memory array to simulate wpdb queries via a custom mock.
*/
class ZoneWarmTest extends TestCase {
/** In-memory warm store: post_id => meta_key => [value, expires_at] */
public static array $store = [];
protected function setUp(): void {
self::$store = [];
$this->setupWpdbMock();
}
private function setupWpdbMock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
public function get_var( string $sql ): ?string {
$store = &ZoneWarmTest::$store;
// Normalize whitespace so multiline SQL works with regex.
$flat = preg_replace( '/\s+/', ' ', $sql );
if ( preg_match( '/SELECT id.*post_id = (\d+).*meta_key = \'([^\']+)\'/', $flat, $m ) ) {
return isset( $store[ $m[1] ][ $m[2] ] ) ? '1' : null;
}
if ( preg_match( '/SELECT meta_value.*post_id = (\d+).*meta_key = \'([^\']+)\'/', $flat, $m ) ) {
$entry = $store[ $m[1] ][ $m[2] ] ?? null;
if ( ! $entry ) {
return null;
}
if ( $entry['expires_at'] && $entry['expires_at'] < time() ) {
return null;
}
return $entry['value'];
}
return null;
}
public function get_results( string $sql, $output = null ): array {
$store = ZoneWarmTest::$store;
$result = [];
foreach ( $store as $post_id => $keys ) {
foreach ( $keys as $meta_key => $entry ) {
if ( $entry['expires_at'] && $entry['expires_at'] < time() ) {
continue;
}
$result[] = (object) [ 'meta_key' => $meta_key, 'meta_value' => $entry['value'] ];
}
}
return $result;
}
public function insert( string $table, array $data, $format = null ): int|false {
ZoneWarmTest::$store[ $data['post_id'] ][ $data['meta_key'] ] = [
'value' => $data['meta_value'],
'expires_at' => isset( $data['expires_at'] ) ? strtotime( $data['expires_at'] ) : null,
];
return 1;
}
public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false {
// For simplicity, find by scanning store.
foreach ( ZoneWarmTest::$store as $post_id => &$keys ) {
foreach ( $keys as $meta_key => &$entry ) {
if ( isset( $data['meta_value'] ) ) {
$entry['value'] = $data['meta_value'];
}
if ( isset( $data['expires_at'] ) ) {
$entry['expires_at'] = strtotime( $data['expires_at'] );
}
}
}
return 1;
}
public function delete( string $table, array $where, $format = null ): int|false {
$post_id = $where['post_id'] ?? null;
$meta_key = $where['meta_key'] ?? null;
if ( $post_id && $meta_key ) {
unset( ZoneWarmTest::$store[ $post_id ][ $meta_key ] );
} elseif ( $post_id ) {
unset( ZoneWarmTest::$store[ $post_id ] );
}
return 1;
}
public function query( string $sql ): int|bool {
return 0;
}
};
}
// ── set / get ────────────────────────────────────────────────────────────
public function test_set_and_get_basic_value(): void {
WPDO_Zone_Warm::set( 1, 'hp_views', '42' );
$this->assertSame( '42', WPDO_Zone_Warm::get( 1, 'hp_views' ) );
}
public function test_get_returns_null_for_missing_key(): void {
$this->assertNull( WPDO_Zone_Warm::get( 99, 'missing_key' ) );
}
public function test_set_overwrites_existing_value(): void {
WPDO_Zone_Warm::set( 1, 'hp_views', '10' );
WPDO_Zone_Warm::set( 1, 'hp_views', '20' );
// The store update mock replaces all entries for simplicity.
$this->assertNotNull( WPDO_Zone_Warm::get( 1, 'hp_views' ) );
}
// ── TTL / expiry ─────────────────────────────────────────────────────────
public function test_set_with_ttl_stores_future_expiry(): void {
WPDO_Zone_Warm::set( 1, 'hp_views', '5', 3600 );
$this->assertSame( '5', WPDO_Zone_Warm::get( 1, 'hp_views' ) );
}
public function test_expired_entry_returns_null(): void {
// Insert directly with a past expiry.
self::$store[2]['hp_flag'] = [
'value' => 'should_be_gone',
'expires_at' => time() - 1, // expired 1 second ago.
];
$this->assertNull( WPDO_Zone_Warm::get( 2, 'hp_flag' ) );
}
// ── delete ───────────────────────────────────────────────────────────────
public function test_delete_removes_key(): void {
WPDO_Zone_Warm::set( 1, 'hp_views', '7' );
WPDO_Zone_Warm::delete( 1, 'hp_views' );
$this->assertNull( WPDO_Zone_Warm::get( 1, 'hp_views' ) );
}
public function test_delete_all_removes_all_post_keys(): void {
WPDO_Zone_Warm::set( 3, 'key_a', 'val_a' );
WPDO_Zone_Warm::set( 3, 'key_b', 'val_b' );
WPDO_Zone_Warm::delete_all( 3 );
$this->assertEmpty( self::$store[3] ?? [] );
}
// ── Additional tests ─────────────────────────────────────────────────────
public function test_table_returns_warm_table_name(): void {
$this->assertSame( 'wp_wpdo_warm', WPDO_Zone_Warm::table() );
}
public function test_delete_specific_key_leaves_other_keys_intact(): void {
WPDO_Zone_Warm::set( 4, 'key_keep', 'val_keep' );
WPDO_Zone_Warm::set( 4, 'key_drop', 'val_drop' );
WPDO_Zone_Warm::delete( 4, 'key_drop' );
$this->assertNull( WPDO_Zone_Warm::get( 4, 'key_drop' ) );
// key_keep should still be readable.
$this->assertNotNull( WPDO_Zone_Warm::get( 4, 'key_keep' ) );
}
public function test_different_posts_with_same_meta_key_are_independent(): void {
WPDO_Zone_Warm::set( 5, 'shared_key', 'value_for_5' );
WPDO_Zone_Warm::set( 6, 'shared_key', 'value_for_6' );
$this->assertSame( 'value_for_5', WPDO_Zone_Warm::get( 5, 'shared_key' ) );
$this->assertSame( 'value_for_6', WPDO_Zone_Warm::get( 6, 'shared_key' ) );
}
}