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:
@@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'] );
|
||||
}
|
||||
}
|
||||
@@ -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 個 module(9 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" );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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() );
|
||||
}
|
||||
}
|
||||
@@ -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'] );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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.0:Module_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 );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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' ) );
|
||||
}
|
||||
}
|
||||
@@ -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' );
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'] );
|
||||
}
|
||||
}
|
||||
@@ -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'] );
|
||||
}
|
||||
}
|
||||
@@ -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'] );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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'] );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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' ) );
|
||||
}
|
||||
}
|
||||
@@ -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' ) );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user