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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
+651
View File
@@ -0,0 +1,651 @@
<?php
declare(strict_types=1);
/**
* PHPUnit bootstrap for 2meet Data Optimizer unit tests.
*
* No WordPress installation required — stubs all globals and functions.
* WPDO_* aliases are created via class-tmdo-back-compat.php so existing
* tests that reference WPDO_* class names continue to work.
*/
require_once dirname( __DIR__ ) . '/vendor/autoload.php';
// ── Constants ──────────────────────────────────────────────────────────────
define( 'ABSPATH', '/fake/wordpress/' );
define( 'TMDO_PATH', dirname( __DIR__ ) . '/' );
define( 'TMDO_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer/' );
define( 'TMDO_FILE', dirname( __DIR__ ) . '/2meet-data-optimizer.php' );
define( 'TMDO_VERSION', '0.1.0' );
define( 'TMDO_DB_VERSION', '2.0.0' );
define( 'TMDO_IS_SQLITE', false );
define( 'TMDO_IS_MYSQL', true );
// Back-compat: tests and AddOns may still reference WPDO_ constants.
define( 'WPDO_PLUGIN_DIR', TMDO_PATH );
define( 'WPDO_PLUGIN_URL', TMDO_URL );
define( 'WPDO_PLUGIN_FILE', TMDO_FILE );
define( 'WPDO_VERSION', TMDO_VERSION );
define( 'WPDO_DB_VERSION', TMDO_DB_VERSION );
define( 'WPDO_IS_SQLITE', TMDO_IS_SQLITE );
define( 'WPDO_IS_MYSQL', TMDO_IS_MYSQL );
define( 'DAY_IN_SECONDS', 86400 );
define( 'HOUR_IN_SECONDS', 3600 );
define( 'MINUTE_IN_SECONDS', 60 );
if ( ! defined( 'TMDO_TABLE_PREFIX' ) ) {
define( 'TMDO_TABLE_PREFIX', 'wpdo_' );
}
if ( ! defined( 'TMDO_CACHE_GROUP' ) ) {
define( 'TMDO_CACHE_GROUP', 'wpdo' );
}
if ( ! defined( 'TMDO_MIN_PHP' ) ) {
define( 'TMDO_MIN_PHP', '8.1' );
}
if ( ! defined( 'TMDO_MIN_WP' ) ) {
define( 'TMDO_MIN_WP', '6.0' );
}
if ( ! defined( 'WPDO_TABLE_PREFIX' ) ) {
define( 'WPDO_TABLE_PREFIX', TMDO_TABLE_PREFIX );
}
if ( ! defined( 'WPDO_CACHE_GROUP' ) ) {
define( 'WPDO_CACHE_GROUP', TMDO_CACHE_GROUP );
}
// Raw-PHP harness has no snapshot storage — disable the FSM transition guard so
// tests can force module states directly (mirrors wp-data-optimizer harness).
if ( ! defined( 'TMDO_FSM_GUARD_DISABLED' ) ) {
define( 'TMDO_FSM_GUARD_DISABLED', true );
}
if ( ! defined( 'WPDO_FSM_GUARD_DISABLED' ) ) {
define( 'WPDO_FSM_GUARD_DISABLED', TMDO_FSM_GUARD_DISABLED );
}
// ── Stub $wpdb ─────────────────────────────────────────────────────────────
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 string $usermeta = 'wp_usermeta';
public string $comments = 'wp_comments';
public int $insert_id = 0;
public string $last_error = '';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%[sdf]/', function () use ( &$i, $args ) {
return $args[ $i++ ] ?? '?';
}, $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 []; }
public function get_col( string $sql, int $col = 0 ): 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, $wf = null ): int|false { return 1; }
public function delete( string $table, array $where, $format = null ): int|false { return 1; }
public function replace( string $table, array $data, $format = null ): int|false { return 1; }
public function query( string $sql ): int|bool { return 1; }
public function get_charset_collate(): string { return 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'; }
public function esc_like( string $s ): string { return addcslashes( $s, '_%\\' ); }
};
if ( ! defined( 'OBJECT' ) ) { define( 'OBJECT', 'OBJECT' ); }
if ( ! defined( 'ARRAY_A' ) ) { define( 'ARRAY_A', 'ARRAY_A' ); }
// ── WordPress function stubs ───────────────────────────────────────────────
if ( ! function_exists( 'trailingslashit' ) ) {
function trailingslashit( string $s ): string { return rtrim( $s, '/' ) . '/'; }
}
if ( ! function_exists( 'sanitize_key' ) ) {
function sanitize_key( string $key ): string {
return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( $key ) );
}
}
if ( ! function_exists( 'absint' ) ) {
function absint( $v ): int { return abs( (int) $v ); }
}
if ( ! function_exists( 'wp_unslash' ) ) {
function wp_unslash( $v ) { return is_string( $v ) ? stripslashes( $v ) : $v; }
}
if ( ! function_exists( 'esc_like' ) ) {
function esc_like( string $s ): string { return addcslashes( $s, '_%\\' ); }
}
if ( ! function_exists( 'current_time' ) ) {
function current_time( string $type, bool $gmt = false ): string|int {
if ( 'timestamp' === $type || 'U' === $type ) { return time(); }
return gmdate( 'Y-m-d H:i:s' );
}
}
if ( ! function_exists( 'wp_parse_args' ) ) {
function wp_parse_args( $args, array $defaults = [] ): array {
if ( is_string( $args ) ) { parse_str( $args, $parsed ); return array_merge( $defaults, $parsed ); }
return array_merge( $defaults, (array) $args );
}
}
// Minimal filter registry — supports add_filter / remove_filter / apply_filters.
$GLOBALS['_wp_filter_callbacks'] = [];
if ( ! function_exists( 'add_action' ) ) {
function add_action( string $hook, $cb, int $p = 10, int $a = 1 ): bool {
$GLOBALS['_wp_filter_callbacks'][ $hook ][] = $cb;
return true;
}
}
if ( ! function_exists( 'add_filter' ) ) {
function add_filter( string $hook, $cb, int $p = 10, int $a = 1 ): bool {
$GLOBALS['_wp_filter_callbacks'][ $hook ][] = $cb;
return true;
}
}
if ( ! function_exists( 'remove_filter' ) ) {
function remove_filter( string $hook, $cb, int $p = 10 ): bool {
if ( isset( $GLOBALS['_wp_filter_callbacks'][ $hook ] ) ) {
$GLOBALS['_wp_filter_callbacks'][ $hook ] = array_values(
array_filter( $GLOBALS['_wp_filter_callbacks'][ $hook ], fn( $c ) => $c !== $cb )
);
}
return true;
}
}
if ( ! function_exists( 'remove_action' ) ) {
function remove_action( string $hook, $cb, int $p = 10 ): bool {
return remove_filter( $hook, $cb, $p );
}
}
if ( ! function_exists( 'apply_filters' ) ) {
function apply_filters( string $hook, $value, ...$args ) {
foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) {
$value = $cb( $value, ...$args );
}
return $value;
}
}
if ( ! function_exists( 'do_action' ) ) {
function do_action( string $hook, ...$args ): void {
foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) {
$cb( ...$args );
}
}
}
if ( ! function_exists( 'wp_next_scheduled' ) ) {
function wp_next_scheduled( string $hook ): int|false { return false; }
}
if ( ! function_exists( 'wp_schedule_event' ) ) {
function wp_schedule_event( int $t, string $r, string $h ): bool { return true; }
}
if ( ! function_exists( 'wp_schedule_single_event' ) ) {
function wp_schedule_single_event( int $ts, string $hook ): bool { return true; }
}
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) {
function wp_clear_scheduled_hook( string $hook ): int|false { return 0; }
}
if ( ! function_exists( 'is_admin' ) ) {
function is_admin(): bool { return false; }
}
if ( ! function_exists( 'is_singular' ) ) {
function is_singular( $t = '' ): bool { return false; }
}
if ( ! function_exists( 'get_the_ID' ) ) {
function get_the_ID(): int|false { return false; }
}
if ( ! function_exists( 'wp_doing_ajax' ) ) {
function wp_doing_ajax(): bool { return false; }
}
if ( ! function_exists( 'sanitize_text_field' ) ) {
function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); }
}
if ( ! function_exists( '__' ) ) {
function __( string $text, string $domain = 'default' ): string { return $text; }
}
if ( ! function_exists( 'esc_html' ) ) {
function esc_html( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); }
}
if ( ! function_exists( 'esc_html__' ) ) {
function esc_html__( string $text, string $domain = 'default' ): string { return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); }
}
if ( ! function_exists( 'esc_html_e' ) ) {
function esc_html_e( string $text, string $domain = 'default' ): void { echo htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); }
}
if ( ! function_exists( 'esc_attr' ) ) {
function esc_attr( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); }
}
if ( ! function_exists( 'esc_url' ) ) {
function esc_url( string $url ): string { return filter_var( $url, FILTER_SANITIZE_URL ) ?: ''; }
}
if ( ! function_exists( 'esc_sql' ) ) {
function esc_sql( $s ): string {
$s = is_string( $s ) ? $s : (string) $s;
return addslashes( $s );
}
}
if ( ! function_exists( '_doing_it_wrong' ) ) {
function _doing_it_wrong( string $fn, string $msg, string $ver ): void {}
}
if ( ! function_exists( 'get_option' ) ) {
function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; }
}
if ( ! function_exists( 'update_option' ) ) {
function update_option( string $key, $value ): bool { $GLOBALS['_wp_options'][ $key ] = $value; return true; }
}
if ( ! function_exists( 'delete_option' ) ) {
function delete_option( string $key ): bool { unset( $GLOBALS['_wp_options'][ $key ] ); return true; }
}
if ( ! function_exists( 'get_post_meta' ) ) {
function get_post_meta( int $post_id, string $key = '', bool $single = false ) {
return $GLOBALS['_wp_postmeta'][ $post_id ][ $key ] ?? ( $single ? '' : [] );
}
}
if ( ! function_exists( 'update_post_meta' ) ) {
function update_post_meta( int $post_id, string $key, $value, $prev = '' ): int|bool {
$GLOBALS['_wp_postmeta'][ $post_id ][ $key ] = $value;
return true;
}
}
if ( ! function_exists( 'get_user_meta' ) ) {
function get_user_meta( int $uid, string $key = '', bool $single = false ) {
return $GLOBALS['_wp_usermeta'][ $uid ][ $key ] ?? ( $single ? '' : [] );
}
}
if ( ! function_exists( 'update_user_meta' ) ) {
function update_user_meta( int $uid, string $key, $value, $prev = '' ): int|bool {
$GLOBALS['_wp_usermeta'][ $uid ][ $key ] = $value;
return true;
}
}
if ( ! function_exists( 'get_term_meta' ) ) {
function get_term_meta( int $tid, string $key = '', bool $single = false ) {
return $GLOBALS['_wp_termmeta'][ $tid ][ $key ] ?? ( $single ? '' : [] );
}
}
if ( ! function_exists( 'update_term_meta' ) ) {
function update_term_meta( int $tid, string $key, $value, $prev = '' ): int|bool {
$GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value;
return true;
}
}
if ( ! function_exists( 'get_comment_meta' ) ) {
function get_comment_meta( int $cid, string $key = '', bool $single = false ) {
return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] );
}
}
if ( ! function_exists( 'update_comment_meta' ) ) {
function update_comment_meta( int $cid, string $key, $value, $prev = '' ): int|bool {
$GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value;
return true;
}
}
if ( ! function_exists( 'get_post_type' ) ) {
function get_post_type( $post_id ) {
return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false;
}
}
if ( ! function_exists( 'get_post_status' ) ) {
function get_post_status( $post_id ) {
return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish';
}
}
if ( ! function_exists( 'is_post_publicly_viewable' ) ) {
function is_post_publicly_viewable( $post_id ): bool {
if ( isset( $GLOBALS['_wp_post_publicly_viewable'][ (int) $post_id ] ) ) {
return (bool) $GLOBALS['_wp_post_publicly_viewable'][ (int) $post_id ];
}
return 'publish' === ( $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish' );
}
}
if ( ! function_exists( 'wp_die' ) ) {
function wp_die( $message = '' ): void { throw new RuntimeException( is_string( $message ) ? $message : 'wp_die' ); }
}
if ( ! function_exists( 'wp_verify_nonce' ) ) {
function wp_verify_nonce( $nonce, string $action = '' ) {
return $GLOBALS['_wp_valid_nonces'][ (string) $nonce ] ?? false;
}
}
if ( ! function_exists( 'wp_create_nonce' ) ) {
function wp_create_nonce( string $action = '' ): string {
$nonce = 'test_nonce_' . md5( $action );
$GLOBALS['_wp_valid_nonces'][ $nonce ] = 1;
return $nonce;
}
}
if ( ! function_exists( 'current_user_can' ) ) {
function current_user_can( string $cap, ...$args ): bool {
if ( ! empty( $args ) ) {
$key = $cap . ':' . implode( ',', array_map( 'strval', $args ) );
if ( isset( $GLOBALS['_wp_current_user_can'][ $key ] ) ) {
return (bool) $GLOBALS['_wp_current_user_can'][ $key ];
}
}
return $GLOBALS['_wp_current_user_can'][ $cap ] ?? false;
}
}
if ( ! function_exists( 'get_current_user_id' ) ) {
function get_current_user_id(): int { return (int) ( $GLOBALS['_wp_current_user_id'] ?? 0 ); }
}
if ( ! function_exists( 'is_multisite' ) ) {
function is_multisite(): bool { return (bool) ( $GLOBALS['_wp_is_multisite'] ?? false ); }
}
if ( ! function_exists( 'is_super_admin' ) ) {
function is_super_admin( ?int $uid = null ): bool { return (bool) ( $GLOBALS['_wp_is_super_admin'] ?? false ); }
}
if ( ! function_exists( 'switch_to_blog' ) ) {
function switch_to_blog( int $blog_id ): bool { $GLOBALS['_wp_current_blog_id'] = $blog_id; return true; }
}
if ( ! function_exists( 'restore_current_blog' ) ) {
function restore_current_blog(): bool { unset( $GLOBALS['_wp_current_blog_id'] ); return true; }
}
if ( ! function_exists( 'get_sites' ) ) {
function get_sites( array $args = [] ): array { return $GLOBALS['_wp_sites'] ?? []; }
}
if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
function is_plugin_active_for_network( string $plugin ): bool {
return (bool) ( $GLOBALS['_wp_plugin_active_for_network'][ $plugin ] ?? false );
}
}
if ( ! function_exists( 'plugin_basename' ) ) {
function plugin_basename( string $file ): string {
return basename( dirname( $file ) ) . '/' . basename( $file );
}
}
if ( ! function_exists( 'wp_generate_password' ) ) {
function wp_generate_password( int $len = 12, bool $special = true ): string {
return substr( str_replace( [ '/', '+', '=' ], '', base64_encode( random_bytes( $len ) ) ), 0, $len );
}
}
if ( ! function_exists( 'wp_json_encode' ) ) {
function wp_json_encode( $data, int $flags = 0 ): string|false { return json_encode( $data, $flags ); }
}
if ( ! function_exists( 'is_serialized' ) ) {
function is_serialized( $data ): bool {
return is_string( $data ) && strlen( $data ) >= 4
&& in_array( $data[0], [ 'a', 's', 'i', 'd', 'b', 'O', 'N' ], true )
&& str_ends_with( $data, ';' );
}
}
if ( ! function_exists( 'maybe_serialize' ) ) {
function maybe_serialize( $data ) { return is_array( $data ) || is_object( $data ) ? serialize( $data ) : $data; }
}
if ( ! function_exists( 'maybe_unserialize' ) ) {
function maybe_unserialize( $value ) {
if ( ! is_string( $value ) ) { return $value; }
$u = @unserialize( $value );
return ( false !== $u || 'b:0;' === $value ) ? $u : $value;
}
}
if ( ! function_exists( 'get_transient' ) ) {
function get_transient( string $key ) { return $GLOBALS['_wp_options'][ '_transient_' . $key ] ?? false; }
}
if ( ! function_exists( 'set_transient' ) ) {
function set_transient( string $key, $value, int $expiry = 0 ): bool {
$GLOBALS['_wp_options'][ '_transient_' . $key ] = $value;
return true;
}
}
if ( ! function_exists( 'delete_transient' ) ) {
function delete_transient( string $key ): bool { unset( $GLOBALS['_wp_options'][ '_transient_' . $key ] ); return true; }
}
if ( ! function_exists( 'wp_rand' ) ) {
function wp_rand( int $min = 0, int $max = 0 ): int { return random_int( $min, $max ?: PHP_INT_MAX ); }
}
if ( ! function_exists( 'is_wp_error' ) ) {
function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; }
}
// Object cache simulation.
$GLOBALS['_wp_cache'] = [];
if ( ! function_exists( 'wp_cache_get' ) ) {
function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; }
}
if ( ! function_exists( 'wp_cache_set' ) ) {
function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool {
$GLOBALS['_wp_cache'][ $group ][ $key ] = $value;
return true;
}
}
if ( ! function_exists( 'wp_cache_delete' ) ) {
function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; }
}
// ── Stub classes ───────────────────────────────────────────────────────────
if ( ! class_exists( 'WP_Query' ) ) {
class WP_Query {
public array $posts = [];
public int $found_posts = 0;
public int $max_num_pages = 0;
private array $args = [];
public function __construct( array $args = [] ) { $this->args = $args; }
public function get( string $key, $default = '' ) { return $this->args[ $key ] ?? $default; }
public function set( string $key, $value ): void { $this->args[ $key ] = $value; }
}
}
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
private string $code;
private string $message;
private array $data;
public function __construct( string $code = '', string $message = '', $data = [] ) {
$this->code = $code;
$this->message = $message;
$this->data = is_array( $data ) ? $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 ( ! class_exists( 'WP_REST_Request' ) ) {
class WP_REST_Request {
private array $params = [];
private array $headers = [];
public function __construct( string $method = 'GET', string $route = '' ) {}
public function get_param( string $key ) { return $this->params[ $key ] ?? null; }
public function set_param( string $key, $value ): void { $this->params[ $key ] = $value; }
public function get_header( string $key ): ?string { return $this->headers[ strtolower( $key ) ] ?? null; }
public function set_header( string $key, string $value ): void { $this->headers[ strtolower( $key ) ] = $value; }
}
}
if ( ! class_exists( 'WP_REST_Response' ) ) {
class WP_REST_Response {
private $data;
private int $status;
private array $headers = [];
public function __construct( $data = null, int $status = 200 ) { $this->data = $data; $this->status = $status; }
public function get_data() { return $this->data; }
public function get_status(): int { return $this->status; }
public function header( string $k, string $v ): void { $this->headers[ $k ] = $v; }
public function get_headers(): array { return $this->headers; }
}
}
if ( ! class_exists( 'WP_REST_Server' ) ) {
class WP_REST_Server {
const READABLE = 'GET';
const CREATABLE = 'POST';
const EDITABLE = 'POST, PUT, PATCH';
const DELETABLE = 'DELETE';
}
}
if ( ! function_exists( 'register_rest_route' ) ) {
function register_rest_route( string $ns, string $route, array $args ): bool { return true; }
}
// WP_CLI stub (needed before loading CLI classes).
if ( ! class_exists( 'WP_CLI' ) ) {
class WP_CLI {
public static function log( string $msg ): void {}
public static function warning( string $msg ): void {}
public static function success( string $msg ): void {}
public static function error( string $msg ): void {}
public static function add_command( string $name, $class ): void {}
}
}
// ── Load plugin classes (TMDO_ prefix, dependency order) ──────────────────
require_once TMDO_PATH . 'includes/class-tmdo-capability.php';
require_once TMDO_PATH . 'includes/class-tmdo-crypto.php';
require_once TMDO_PATH . 'includes/class-tmdo-safe-unserialize.php';
require_once TMDO_PATH . 'includes/class-tmdo-db.php';
require_once TMDO_PATH . 'includes/class-tmdo-logger.php';
require_once TMDO_PATH . 'includes/class-tmdo-feature-flags.php';
require_once TMDO_PATH . 'includes/class-tmdo-sqlite-compat.php';
require_once TMDO_PATH . 'includes/class-tmdo-installer.php';
require_once TMDO_PATH . 'includes/class-tmdo-schema-registry.php';
require_once TMDO_PATH . 'includes/class-tmdo-custom-table-registry.php';
require_once TMDO_PATH . 'includes/trait-tmdo-anti-eav-aware.php';
require_once TMDO_PATH . 'includes/class-tmdo-hook-bus-bridge.php';
require_once TMDO_PATH . 'includes/class-tmdo-conflict-monitor.php';
require_once TMDO_PATH . 'includes/class-tmdo-compatibility.php';
// Interceptors.
require_once TMDO_PATH . 'includes/interceptors/class-tmdo-interceptor-base.php';
require_once TMDO_PATH . 'includes/interceptors/class-tmdo-sync-bridge.php';
// Zones.
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-hot.php';
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-warm.php';
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-cold.php';
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-archive.php';
// Query.
require_once TMDO_PATH . 'includes/query/class-tmdo-query-interceptor-base.php';
require_once TMDO_PATH . 'includes/query/class-tmdo-query-router.php';
require_once TMDO_PATH . 'includes/query/class-tmdo-post-query-router.php';
// Migration.
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-base.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-engine.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-hot-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-warm-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-cold-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-archive-migration.php';
// Integrations.
require_once TMDO_PATH . 'includes/integrations/class-tmdo-term-comment-garbage-filter.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-term-comment-misc-bucket.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-member-fields.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-post-fields.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-points-manager.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-demo-entity-counter.php';
// Cache + Classifier.
require_once TMDO_PATH . 'includes/class-tmdo-cache-layer.php';
require_once TMDO_PATH . 'includes/class-tmdo-zone-classifier.php';
// Public API.
require_once TMDO_PATH . 'includes/class-tmdo-api.php';
// v2 Upgrader.
require_once TMDO_PATH . 'includes/class-tmdo-v2-upgrader.php';
// Snapshot system.
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-manager.php';
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-writer.php';
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-reader.php';
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-pruner.php';
// FSM safety guard.
require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php';
// Diagnostic — do NOT load site-health here; its DB checks would fail with
// the stub $wpdb and trigger false schema_drift criticals in HealthCronTest.
// TMDO_Site_Health loads lazily if needed by integration tests.
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-health-cron.php';
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-monthly-summary.php';
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-site-metrics-collector.php';
// Notifiers.
require_once TMDO_PATH . 'includes/notifications/abstract-class-tmdo-notifier.php';
require_once TMDO_PATH . 'includes/notifications/class-tmdo-email-notifier.php';
require_once TMDO_PATH . 'includes/notifications/class-tmdo-slack-notifier.php';
require_once TMDO_PATH . 'includes/notifications/class-tmdo-discord-notifier.php';
require_once TMDO_PATH . 'includes/notifications/class-tmdo-telegram-notifier.php';
// FSM advisor.
require_once TMDO_PATH . 'includes/advisor/class-tmdo-fsm-advisor.php';
require_once TMDO_PATH . 'includes/advisor/class-tmdo-module-rules.php';
require_once TMDO_PATH . 'includes/advisor/class-tmdo-module-detector.php';
require_once TMDO_PATH . 'includes/advisor/class-tmdo-fsm-automator.php';
// Export.
require_once TMDO_PATH . 'includes/export/class-tmdo-csv-writer.php';
// REST API.
require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
// Engine (v2.0.0).
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-cache-orchestrator.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-query-compiler.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-schema-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php';
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-term.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-comment.php';
// Migration Orchestrator (needs Entity_Registry).
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
// Options Manager.
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
// Stress testers + shadow verifiers.
require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-commentmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-term-comment-shadow-verifier.php';
require_once TMDO_PATH . 'includes/class-tmdo-term-comment-backfill.php';
require_once TMDO_PATH . 'includes/class-tmdo-term-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-comment-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php';
// Core.
require_once TMDO_PATH . 'includes/class-tmdo-core.php';
// CLI.
require_once TMDO_PATH . 'cli/class-tmdo-cli.php';
require_once TMDO_PATH . 'cli/class-tmdo-cli-v2.php';
require_once TMDO_PATH . 'cli/class-tmdo-cli-member.php';
require_once TMDO_PATH . 'cli/class-tmdo-cli-post.php';
require_once TMDO_PATH . 'cli/class-tmdo-cli-term-comment.php';
// Back-compat aliases (WPDO_* → TMDO_*) — must be last.
require_once TMDO_PATH . 'includes/class-tmdo-back-compat.php';
// Global FSM Guard bypass for all unit tests except FSMGuardTest itself.
// FSMGuardTest::setUp() clears _wp_filter_callbacks to restore guard behavior.
if ( ! function_exists( '__return_true' ) ) {
function __return_true(): bool { return true; }
}
add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
// ── AddOn stubs (classes moved to optional AddOns) ─────────────────────────
// TMDO_Listing_Stats → 2meet-data-optimizer-hivepress-addon.
// Tests that hit REST endpoints using listing stats receive stub responses.
if ( ! class_exists( 'TMDO_Listing_Stats' ) ) {
class TMDO_Listing_Stats {
public static function register(): void {}
public static function get_view_count( int $post_id ): int { return 0; }
public static function increment_view( int $post_id, string $ip = '' ): int { return 0; }
public static function is_rate_limited( int $post_id, string $ip ): bool { return false; }
}
class_alias( 'TMDO_Listing_Stats', 'WPDO_Listing_Stats' );
}
@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Performance benchmark integration tests.
*
* Exercises Zone A (Hot), Zone B (Warm), and Zone C (Cold) with N=200
* write + read operations and verifies data integrity. Timing is captured
* and printed to STDOUT so it appears in --testdox output. No hard timing
* assertions are made — correctness assertions guard against regressions.
*
* Run alone: ./vendor/bin/phpunit --configuration phpunit-integration.xml
* --filter BenchmarkIntegrationTest --testdox
*/
class BenchmarkIntegrationTest extends TestCase {
private const N = 200; // Rows per benchmark zone.
private const POST_TYPE = 'bench';
/** Zone A table: wp_itest_wpdo_hot_bench */
private static string $hot_table;
/** Zone B table: wp_itest_wpdo_warm_bench (isolated from other warm tests) */
private static string $warm_table;
/** Zone C table: wp_itest_wpdo_cold_bench */
private static string $cold_table;
/** Collected timing results printed in tearDownAfterClass(). */
private static array $report = [];
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
self::$hot_table = $wpdb->prefix . 'wpdo_hot_bench';
self::$warm_table = $wpdb->prefix . 'wpdo_warm_bench';
self::$cold_table = $wpdb->prefix . 'wpdo_cold_bench';
// Zone A table.
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `" . self::$hot_table . "` (
post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
bench_val DECIMAL(10,2) DEFAULT NULL,
updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Zone B table (KV schema).
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `" . self::$warm_table . "` (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
meta_key VARCHAR(255) NOT NULL DEFAULT '',
meta_value LONGTEXT DEFAULT NULL,
expires_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY post_meta (post_id, meta_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Zone C table.
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `" . self::$cold_table . "` (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
data LONGTEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
UNIQUE KEY ui_post_id (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$hot_table . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$cold_table . "`" );
// Print timing summary.
fwrite( STDOUT, "\n\n ── Benchmark Results (N=" . self::N . " per zone) ──────────────────────\n" );
foreach ( self::$report as $label => $ms ) {
fwrite( STDOUT, sprintf( " %-40s %7.1f ms\n", $label, $ms ) );
}
fwrite( STDOUT, " ──────────────────────────────────────────────────────\n\n" );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( "TRUNCATE TABLE `" . self::$hot_table . "`" );
$wpdb->query( "TRUNCATE TABLE `" . self::$warm_table . "`" );
$wpdb->query( "TRUNCATE TABLE `" . self::$cold_table . "`" );
$GLOBALS['_wp_cache'] = [];
}
// ── Zone A (Hot) ─────────────────────────────────────────────────────────
public function test_zone_a_bulk_write_performance(): void {
global $wpdb;
$now = gmdate( 'Y-m-d H:i:s' );
$start = microtime( true );
for ( $i = 1; $i <= self::N; $i++ ) {
$wpdb->query(
$wpdb->prepare(
"INSERT INTO `" . self::$hot_table . "` (post_id, bench_val, updated_at)
VALUES (%d, %f, %s)
ON DUPLICATE KEY UPDATE bench_val = VALUES(bench_val), updated_at = VALUES(updated_at)",
$i,
$i * 10.0,
$now
)
);
}
$ms = ( microtime( true ) - $start ) * 1000;
self::$report['Zone A: ' . self::N . ' UPSERT writes'] = $ms;
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$hot_table . "`" );
$this->assertSame( self::N, $count, 'Zone A: all rows written' );
}
public function test_zone_a_bulk_read_performance(): void {
global $wpdb;
// Seed data.
$now = gmdate( 'Y-m-d H:i:s' );
for ( $i = 1; $i <= self::N; $i++ ) {
$wpdb->query( $wpdb->prepare(
"INSERT INTO `" . self::$hot_table . "` (post_id, bench_val, updated_at) VALUES (%d, %f, %s)",
$i, $i * 10.0, $now
) );
}
// Benchmark individual point-reads.
$start = microtime( true );
$values = [];
for ( $i = 1; $i <= self::N; $i++ ) {
$values[] = $wpdb->get_var(
$wpdb->prepare( "SELECT bench_val FROM `" . self::$hot_table . "` WHERE post_id = %d", $i )
);
}
$ms = ( microtime( true ) - $start ) * 1000;
self::$report['Zone A: ' . self::N . ' point reads'] = $ms;
$this->assertCount( self::N, $values, 'Zone A: all rows readable' );
$this->assertSame( '10.00', $values[0] ); // post_id=1 → 1*10=10
}
public function test_zone_a_filtered_query_performance(): void {
global $wpdb;
$now = gmdate( 'Y-m-d H:i:s' );
for ( $i = 1; $i <= self::N; $i++ ) {
$wpdb->query( $wpdb->prepare(
"INSERT INTO `" . self::$hot_table . "` (post_id, bench_val, updated_at) VALUES (%d, %f, %s)",
$i, $i * 10.0, $now
) );
}
// Filtered query: bench_val >= 1000 (100 rows).
$start = microtime( true );
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, bench_val FROM `" . self::$hot_table . "` WHERE bench_val >= %f ORDER BY bench_val ASC",
1000.0
),
ARRAY_A
);
$ms = ( microtime( true ) - $start ) * 1000;
self::$report['Zone A: filtered query (half dataset)'] = $ms;
$this->assertCount( 101, $rows, 'Zone A: filter returns correct row count' );
$this->assertSame( '1000.00', $rows[0]['bench_val'] );
}
// ── Zone B (Warm) ─────────────────────────────────────────────────────────
public function test_zone_b_bulk_write_performance(): void {
global $wpdb;
$now = gmdate( 'Y-m-d H:i:s' );
$start = microtime( true );
for ( $i = 1; $i <= self::N; $i++ ) {
$wpdb->query(
$wpdb->prepare(
"INSERT INTO `" . self::$warm_table . "`
(post_id, meta_key, meta_value, created_at)
VALUES (%d, %s, %s, %s)
ON DUPLICATE KEY UPDATE meta_value = VALUES(meta_value)",
$i, 'bench_views', (string) $i, $now
)
);
}
$ms = ( microtime( true ) - $start ) * 1000;
self::$report['Zone B: ' . self::N . ' KV writes'] = $ms;
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$warm_table . "`" );
$this->assertSame( self::N, $count, 'Zone B: all KV rows written' );
}
public function test_zone_b_bulk_read_performance(): void {
global $wpdb;
$now = gmdate( 'Y-m-d H:i:s' );
for ( $i = 1; $i <= self::N; $i++ ) {
$wpdb->query( $wpdb->prepare(
"INSERT INTO `" . self::$warm_table . "` (post_id, meta_key, meta_value, created_at) VALUES (%d, %s, %s, %s)",
$i, 'bench_views', (string) $i, $now
) );
}
$start = microtime( true );
$values = [];
for ( $i = 1; $i <= self::N; $i++ ) {
$values[] = $wpdb->get_var( $wpdb->prepare(
"SELECT meta_value FROM `" . self::$warm_table . "` WHERE post_id = %d AND meta_key = %s",
$i, 'bench_views'
) );
}
$ms = ( microtime( true ) - $start ) * 1000;
self::$report['Zone B: ' . self::N . ' KV reads'] = $ms;
$this->assertCount( self::N, $values, 'Zone B: all KV rows readable' );
$this->assertSame( '1', $values[0] ); // post_id=1 → value=1
}
// ── Zone C (Cold) ─────────────────────────────────────────────────────────
public function test_zone_c_bulk_write_performance(): void {
global $wpdb;
$now = gmdate( 'Y-m-d H:i:s' );
$start = microtime( true );
for ( $i = 1; $i <= self::N; $i++ ) {
$json = wp_json_encode( [
'hp_description' => 'Benchmark listing description number ' . $i,
'hp_website' => 'https://listing' . $i . '.example.com',
'hp_facebook' => 'https://facebook.com/listing' . $i,
] );
$wpdb->query(
$wpdb->prepare(
"INSERT INTO `" . self::$cold_table . "` (post_id, data, updated_at)
VALUES (%d, %s, %s)
ON DUPLICATE KEY UPDATE data = VALUES(data), updated_at = VALUES(updated_at)",
$i, $json, $now
)
);
}
$ms = ( microtime( true ) - $start ) * 1000;
self::$report['Zone C: ' . self::N . ' JSON blob writes'] = $ms;
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$cold_table . "`" );
$this->assertSame( self::N, $count, 'Zone C: all JSON rows written' );
}
public function test_zone_c_bulk_read_performance(): void {
global $wpdb;
$now = gmdate( 'Y-m-d H:i:s' );
for ( $i = 1; $i <= self::N; $i++ ) {
$json = wp_json_encode( [
'hp_description' => 'Description ' . $i,
'hp_website' => 'https://listing' . $i . '.example.com',
] );
$wpdb->query( $wpdb->prepare(
"INSERT INTO `" . self::$cold_table . "` (post_id, data, updated_at) VALUES (%d, %s, %s)",
$i, $json, $now
) );
}
$start = microtime( true );
$decoded = 0;
for ( $i = 1; $i <= self::N; $i++ ) {
$json = $wpdb->get_var( $wpdb->prepare(
"SELECT data FROM `" . self::$cold_table . "` WHERE post_id = %d",
$i
) );
$data = json_decode( (string) $json, true );
if ( is_array( $data ) && isset( $data['hp_description'] ) ) {
$decoded++;
}
}
$ms = ( microtime( true ) - $start ) * 1000;
self::$report['Zone C: ' . self::N . ' JSON blob reads'] = $ms;
$this->assertSame( self::N, $decoded, 'Zone C: all JSON blobs readable' );
}
}
@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Comment_Stress_Tester (v2.13.1).
*
* Verifies the comment stress tester contract:
* - State machine: start / get_state / get_progress / cancel
* - count_test_comments() matches email-domain marker
* - cleanup() removes test comments + cascade
* - Input validation throws / errors correctly
* - Run benchmark structure
*
* Note: Realistic mode tests (wp_insert_comment path) use bootstrap stub which
* inserts directly without firing filter/action chain.
*/
class CommentStressTesterTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const COMMENTS = 'wp_itest_comments';
private const COMMENTMETA = 'wp_itest_commentmeta';
private static int $test_post_id = 0;
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Comment_Stress_Tester' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-comment-stress-tester.php';
}
$wpdb->posts = self::POSTS;
$wpdb->comments = self::COMMENTS;
$wpdb->commentmeta = self::COMMENTMETA;
$wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::POSTS . '` (
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_title text NOT NULL DEFAULT "",
post_status varchar(20) NOT NULL DEFAULT "publish",
post_type varchar(20) NOT NULL DEFAULT "post",
comment_count bigint(20) NOT NULL DEFAULT 0,
PRIMARY KEY (ID)
) DEFAULT CHARACTER SET utf8mb4' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::COMMENTS . '` (
comment_ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
comment_post_ID bigint(20) unsigned NOT NULL DEFAULT 0,
comment_author tinytext NOT NULL,
comment_author_email varchar(100) NOT NULL DEFAULT "",
comment_author_url varchar(200) NOT NULL DEFAULT "",
comment_author_IP varchar(100) NOT NULL DEFAULT "",
comment_date datetime NOT NULL DEFAULT "1970-01-01 00:00:00",
comment_date_gmt datetime NOT NULL DEFAULT "1970-01-01 00:00:00",
comment_content text NOT NULL,
comment_karma int(11) NOT NULL DEFAULT 0,
comment_approved varchar(20) NOT NULL DEFAULT "1",
comment_agent varchar(255) NOT NULL DEFAULT "",
comment_type varchar(20) NOT NULL DEFAULT "comment",
comment_parent bigint(20) unsigned NOT NULL DEFAULT 0,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (comment_ID),
KEY comment_author_email (comment_author_email(10)),
KEY comment_post_ID (comment_post_ID)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::COMMENTMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
comment_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY comment_id (comment_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4' );
// Seed a single fixture post to satisfy post_exists() checks.
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' );
$wpdb->insert( self::POSTS, array(
'post_title' => 'WPDO Comment Stress Fixture Post',
'post_status' => 'publish',
'post_type' => 'post',
) );
self::$test_post_id = (int) $wpdb->insert_id;
}
public static function tearDownAfterClass(): void {
global $wpdb;
foreach ( array( self::COMMENTS, self::COMMENTMETA ) as $tbl ) {
$wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' );
}
// Don't drop wp_itest_posts — shared fixture across test classes.
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTS . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTMETA . '`' );
// Reset state per test so each starts idle.
unset( $GLOBALS['_wp_options'][ WPDO_Comment_Stress_Tester::OPT_STATE ] );
unset( $GLOBALS['_wp_transients'][ WPDO_Comment_Stress_Tester::CANCEL_FLAG ] );
unset( $GLOBALS['_wp_transients']['wpdo_comment_stress_pump_lock'] );
}
// ── create() (fast-path direct SQL) ──────────────────────────────────────
public function test_create_inserts_comments_for_post(): void {
$result = WPDO_Comment_Stress_Tester::create( self::$test_post_id, 5 );
$this->assertSame( 5, $result['created'] );
$this->assertSame( self::$test_post_id, $result['post_id'] );
$this->assertNotNull( $result['first_id'] );
$this->assertNotNull( $result['last_id'] );
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTS . '`' );
$this->assertSame( 5, $count );
}
public function test_create_uses_stress_email_domain(): void {
WPDO_Comment_Stress_Tester::create( self::$test_post_id, 3 );
global $wpdb;
$prefix_count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `" . self::COMMENTS . "` WHERE comment_author_email LIKE %s",
'%@' . WPDO_Comment_Stress_Tester::TEST_EMAIL_DOMAIN
)
);
$this->assertSame( 3, $prefix_count );
}
public function test_create_seeds_commentmeta_keys(): void {
WPDO_Comment_Stress_Tester::create( self::$test_post_id, 3 );
global $wpdb;
$total_meta = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTMETA . '`' );
// 3 comments × 1 key (hp_rating) = 3.
$this->assertSame( 3, $total_meta );
}
public function test_create_rejects_bad_post_id(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Comment_Stress_Tester::create( 0, 3 );
}
public function test_create_rejects_zero_count(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Comment_Stress_Tester::create( self::$test_post_id, 0 );
}
public function test_create_rejects_excessive_count(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Comment_Stress_Tester::create( self::$test_post_id, 100001 );
}
// ── count_test_comments() ────────────────────────────────────────────────
public function test_count_test_comments_returns_zero_for_empty(): void {
$this->assertSame( 0, WPDO_Comment_Stress_Tester::count_test_comments() );
}
public function test_count_test_comments_counts_only_stress_emails(): void {
WPDO_Comment_Stress_Tester::create( self::$test_post_id, 4 );
global $wpdb;
$wpdb->insert( self::COMMENTS, array(
'comment_post_ID' => self::$test_post_id,
'comment_author' => 'Real',
'comment_author_email' => 'real@example.com',
'comment_content' => 'Real comment',
'comment_approved' => '1',
) );
$this->assertSame( 4, WPDO_Comment_Stress_Tester::count_test_comments() );
}
// ── cleanup() ─────────────────────────────────────────────────────────────
public function test_cleanup_removes_test_comments_and_cascade(): void {
WPDO_Comment_Stress_Tester::create( self::$test_post_id, 5 );
$this->assertSame( 5, WPDO_Comment_Stress_Tester::count_test_comments() );
$result = WPDO_Comment_Stress_Tester::cleanup();
$this->assertSame( 5, $result['deleted_comments'] );
global $wpdb;
$this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTS . '`' ) );
$this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTMETA . '`' ) );
}
public function test_cleanup_preserves_non_stress_comments(): void {
global $wpdb;
$wpdb->insert( self::COMMENTS, array(
'comment_post_ID' => self::$test_post_id,
'comment_author' => 'Real',
'comment_author_email' => 'real@example.com',
'comment_content' => 'Real comment',
'comment_approved' => '1',
) );
WPDO_Comment_Stress_Tester::create( self::$test_post_id, 3 );
$result = WPDO_Comment_Stress_Tester::cleanup();
$this->assertSame( 3, $result['deleted_comments'] );
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTS . '`' );
$this->assertSame( 1, $remaining );
}
public function test_cleanup_idempotent_on_empty(): void {
$first = WPDO_Comment_Stress_Tester::cleanup();
$second = WPDO_Comment_Stress_Tester::cleanup();
$this->assertSame( 0, $first['deleted_comments'] );
$this->assertSame( 0, $second['deleted_comments'] );
}
// ── State machine ────────────────────────────────────────────────────────
public function test_get_state_returns_empty_when_idle(): void {
$this->assertSame( array(), WPDO_Comment_Stress_Tester::get_state() );
}
public function test_get_progress_returns_idle_when_no_state(): void {
$progress = WPDO_Comment_Stress_Tester::get_progress( false );
$this->assertSame( 'idle', $progress['status'] );
}
public function test_start_persists_state_with_running_status(): void {
$result = WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10, 'fast', 5 );
$this->assertTrue( $result['ok'], 'start should succeed' );
$state = $result['state'];
$this->assertSame( 'running', $state['status'] );
$this->assertSame( self::$test_post_id, $state['post_id'] );
$this->assertSame( 'fast', $state['mode'] );
$this->assertSame( 10, $state['target'] );
$this->assertSame( 5, $state['batch_size'] );
}
public function test_start_rejects_unknown_post(): void {
$result = WPDO_Comment_Stress_Tester::start( 999999, 10 );
$this->assertFalse( $result['ok'] );
$this->assertStringContainsString( 'unknown_post', $result['error'] );
}
public function test_start_rejects_invalid_mode(): void {
$result = WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10, 'turbo' );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'invalid mode', $result['error'] );
}
public function test_start_rejects_concurrent_run(): void {
WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10 );
$result = WPDO_Comment_Stress_Tester::start( self::$test_post_id, 5 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'already_running', $result['error'] );
}
public function test_run_batch_advances_processed_count(): void {
WPDO_Comment_Stress_Tester::start( self::$test_post_id, 6, 'fast', 3 );
WPDO_Comment_Stress_Tester::run_batch();
$progress = WPDO_Comment_Stress_Tester::get_progress( false );
$this->assertSame( 3, $progress['processed'] );
$this->assertSame( 1, $progress['batches_done'] );
$this->assertSame( 'running', $progress['status'] );
WPDO_Comment_Stress_Tester::run_batch();
$progress = WPDO_Comment_Stress_Tester::get_progress( false );
$this->assertSame( 6, $progress['processed'] );
$this->assertSame( 'completed', $progress['status'] );
}
public function test_cancel_marks_state_as_cancelled(): void {
WPDO_Comment_Stress_Tester::start( self::$test_post_id, 100, 'fast', 50 );
$result = WPDO_Comment_Stress_Tester::cancel();
$this->assertTrue( $result['ok'] );
$this->assertSame( 'cancelled', $result['state']['status'] );
// In-flight batch run after cancel must NOT bump status back to running.
WPDO_Comment_Stress_Tester::run_batch();
$state = WPDO_Comment_Stress_Tester::get_state();
$this->assertSame( 'cancelled', $state['status'] );
}
public function test_cancel_returns_no_active_job_when_idle(): void {
$result = WPDO_Comment_Stress_Tester::cancel();
$this->assertTrue( $result['ok'] );
$this->assertSame( 'no_active_job', $result['message'] ?? '' );
}
public function test_get_progress_includes_pct_and_eta_keys(): void {
WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10, 'fast', 5 );
WPDO_Comment_Stress_Tester::run_batch();
$progress = WPDO_Comment_Stress_Tester::get_progress( false );
$this->assertArrayHasKey( 'pct', $progress );
$this->assertArrayHasKey( 'rate_per_sec', $progress );
$this->assertArrayHasKey( 'elapsed_sec', $progress );
$this->assertArrayHasKey( 'eta_sec', $progress );
$this->assertArrayHasKey( 'test_comment_count', $progress );
$this->assertSame( 50.0, $progress['pct'] );
}
public function test_run_benchmark_returns_structured_payload(): void {
WPDO_Comment_Stress_Tester::start( self::$test_post_id, 4, 'fast', 4 );
WPDO_Comment_Stress_Tester::run_batch();
$state = WPDO_Comment_Stress_Tester::get_state();
$this->assertSame( 'completed', $state['status'] );
$this->assertIsArray( $state['benchmark'] );
$this->assertArrayHasKey( 'write', $state['benchmark'] );
$this->assertArrayHasKey( 'db_sizes', $state['benchmark'] );
$this->assertSame( self::$test_post_id, $state['benchmark']['post_id'] );
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Commentmeta_Cleaner — wp_commentmeta garbage cleanup (v2.12.0).
*
* Verifies count_garbage() and delete_garbage() against a real MariaDB test table:
* - target=wxr_import → meta_key LIKE '_wxr_import_%'
* - target=demo_data → meta_key LIKE '_2meet_demo_%'
* - target=transients → meta_key LIKE '_transient_%' OR LIKE '_transient_timeout_%'
* - target=orphan_post_meta → meta_key IN known orphan post-domain keys
* - target=all → union of all four
*/
class CommentmetaCleanerIntegrationTest extends TestCase {
private const COMMENTMETA = 'wp_itest_commentmeta';
public static function setUpBeforeClass(): void {
global $wpdb;
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-commentmeta-cleaner.php';
$wpdb->commentmeta = self::COMMENTMETA;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTMETA . '`' );
$wpdb->query(
'CREATE TABLE `' . self::COMMENTMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
comment_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY comment_id (comment_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTMETA . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTMETA . '`' );
}
private function seed( array $rows ): void {
global $wpdb;
foreach ( $rows as $row ) {
$wpdb->insert( self::COMMENTMETA, $row );
}
}
// ── count_garbage ────────────────────────────────────────────────────────
public function test_count_garbage_returns_zero_for_empty_table(): void {
$counts = WPDO_Commentmeta_Cleaner::count_garbage( 'all' );
$this->assertSame( 0, $counts['wxr_import'] );
$this->assertSame( 0, $counts['demo_data'] );
$this->assertSame( 0, $counts['transients'] );
$this->assertSame( 0, $counts['orphan_post_meta'] );
$this->assertSame( 0, $counts['total'] );
}
public function test_count_garbage_counts_wxr_import(): void {
$this->seed( array(
array( 'comment_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
array( 'comment_id' => 2, 'meta_key' => '_wxr_import_post', 'meta_value' => 'b' ),
array( 'comment_id' => 3, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
) );
$counts = WPDO_Commentmeta_Cleaner::count_garbage( 'wxr_import' );
$this->assertSame( 2, $counts['wxr_import'] );
$this->assertSame( 2, $counts['total'] );
}
public function test_count_garbage_counts_orphan_post_meta(): void {
$this->seed( array(
array( 'comment_id' => 1, 'meta_key' => '_hp_price', 'meta_value' => '99' ),
array( 'comment_id' => 1, 'meta_key' => '_hp_status', 'meta_value' => 'publish' ),
array( 'comment_id' => 2, 'meta_key' => '_thumbnail_id', 'meta_value' => '50' ),
array( 'comment_id' => 3, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
array( 'comment_id' => 4, 'meta_key' => 'note_group', 'meta_value' => 'foo' ),
) );
$counts = WPDO_Commentmeta_Cleaner::count_garbage( 'orphan_post_meta' );
$this->assertSame( 3, $counts['orphan_post_meta'] );
$this->assertSame( 3, $counts['total'] );
}
public function test_count_garbage_all_unions_four_buckets(): void {
$this->seed( array(
array( 'comment_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
array( 'comment_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
array( 'comment_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
array( 'comment_id' => 4, 'meta_key' => '_hp_price', 'meta_value' => '99' ),
array( 'comment_id' => 5, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
) );
$counts = WPDO_Commentmeta_Cleaner::count_garbage( 'all' );
$this->assertSame( 1, $counts['wxr_import'] );
$this->assertSame( 1, $counts['demo_data'] );
$this->assertSame( 1, $counts['transients'] );
$this->assertSame( 1, $counts['orphan_post_meta'] );
$this->assertSame( 4, $counts['total'] );
}
// ── delete_garbage ────────────────────────────────────────────────────────
public function test_delete_garbage_removes_targeted_rows_only(): void {
$this->seed( array(
array( 'comment_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
array( 'comment_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
array( 'comment_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
array( 'comment_id' => 4, 'meta_key' => '_hp_price', 'meta_value' => '99' ),
array( 'comment_id' => 5, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
array( 'comment_id' => 6, 'meta_key' => 'note_group', 'meta_value' => 'foo' ),
) );
$deleted = WPDO_Commentmeta_Cleaner::delete_garbage( 'all' );
$this->assertSame( 1, $deleted['wxr_import'] );
$this->assertSame( 1, $deleted['demo_data'] );
$this->assertSame( 1, $deleted['transients'] );
$this->assertSame( 1, $deleted['orphan_post_meta'] );
$this->assertSame( 4, $deleted['total'] );
global $wpdb;
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTMETA . '`' );
$this->assertSame( 2, $remaining, 'hp_rating + note_group must survive' );
}
public function test_delete_garbage_orphan_post_meta_specific(): void {
$this->seed( array(
array( 'comment_id' => 1, 'meta_key' => '_hp_price', 'meta_value' => '99' ),
array( 'comment_id' => 2, 'meta_key' => '_hp_featured', 'meta_value' => '1' ),
array( 'comment_id' => 3, 'meta_key' => '_edit_lock', 'meta_value' => '111:1' ),
array( 'comment_id' => 4, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
array( 'comment_id' => 5, 'meta_key' => 'note_group', 'meta_value' => 'foo' ),
) );
$deleted = WPDO_Commentmeta_Cleaner::delete_garbage( 'orphan_post_meta' );
$this->assertSame( 3, $deleted['orphan_post_meta'] );
$this->assertSame( 3, $deleted['total'] );
global $wpdb;
$keys = $wpdb->get_col( 'SELECT meta_key FROM `' . self::COMMENTMETA . '` ORDER BY meta_key' );
$this->assertSame( array( 'hp_rating', 'note_group' ), $keys );
}
public function test_delete_garbage_idempotent_on_clean_table(): void {
$this->seed( array(
array( 'comment_id' => 1, 'meta_key' => 'hp_rating', 'meta_value' => '5' ),
) );
$first = WPDO_Commentmeta_Cleaner::delete_garbage( 'all' );
$second = WPDO_Commentmeta_Cleaner::delete_garbage( 'all' );
$this->assertSame( 0, $first['total'] );
$this->assertSame( 0, $second['total'] );
}
public function test_invalid_target_throws(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Commentmeta_Cleaner::count_garbage( 'bogus' );
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Crypto::migrate_v1_to_v2() (v2.15.0).
*
* Verifies the bulk migration path against a real wp_itest_options table:
* - Mixed format input (v1 / v2 / plaintext / empty) all classified correctly
* - Counts returned accurately
* - Idempotency: second run is no-op (all v2)
* - Non-wpdo prefix excluded from sweep
*/
class CryptoMigrationTest extends TestCase {
private const TEST_PREFIX = 'wp_itest_';
public static function setUpBeforeClass(): void {
global $wpdb;
$wpdb->prefix = self::TEST_PREFIX;
$wpdb->options = self::TEST_PREFIX . 'options';
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::TEST_PREFIX . 'options` (
option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
option_name varchar(191) NOT NULL DEFAULT "",
option_value longtext NOT NULL,
autoload varchar(20) NOT NULL DEFAULT "yes",
PRIMARY KEY (option_id),
UNIQUE KEY option_name (option_name)
) DEFAULT CHARACTER SET utf8mb4'
);
// Define WP auth constants for stable key derivation.
if ( ! defined( 'AUTH_KEY' ) ) {
define( 'AUTH_KEY', 'integration_auth_key_long_enough_xxxxxxxxxxxxxxxxxxxxxx' );
}
if ( ! defined( 'SECURE_AUTH_SALT' ) ) {
define( 'SECURE_AUTH_SALT', 'integration_secure_auth_salt_long_xxxxxxxxxxxxxxxxxxxx' );
}
}
protected function setUp(): void {
global $wpdb;
// Clear all wpdo_* options before each test for isolation.
$wpdb->query( "DELETE FROM `" . self::TEST_PREFIX . "options` WHERE option_name LIKE 'wpdo_%' OR option_name LIKE 'unrelated_%'" );
}
public function test_migrate_mixed_format_inputs(): void {
// Set up: 2 v1 blobs, 1 v2 blob, 1 plaintext, 1 unrelated (non-wpdo).
$plain1 = 'https://hooks.slack.com/services/legacy1';
$plain2 = 'https://discord.com/api/webhooks/legacy2';
$this->insert_v1_option( 'wpdo_legacy_slack', $plain1 );
$this->insert_v1_option( 'wpdo_legacy_discord', $plain2 );
// Already v2.
$this->set_option_raw( 'wpdo_already_v2', WPDO_Crypto::encrypt( 'already encrypted' ) );
// Plaintext.
$this->set_option_raw( 'wpdo_plaintext_secret', 'just text' );
// Unrelated prefix — must NOT be touched.
$this->set_option_raw( 'unrelated_secret', 'should be ignored' );
$counts = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' );
// Scanned 4 wpdo_* options (unrelated_ excluded).
$this->assertSame( 4, $counts['scanned'] );
$this->assertSame( 2, $counts['migrated'] );
$this->assertSame( 1, $counts['already_v2'] );
$this->assertSame( 1, $counts['plaintext'] );
$this->assertSame( 0, $counts['failed'] );
// Verify v1 blobs were upgraded to v2 and decrypt correctly.
$this->assertSame( 'v2', WPDO_Crypto::format_version( 'wpdo_legacy_slack' ) );
$this->assertSame( 'v2', WPDO_Crypto::format_version( 'wpdo_legacy_discord' ) );
$this->assertSame( $plain1, WPDO_Crypto::get_option( 'wpdo_legacy_slack' ) );
$this->assertSame( $plain2, WPDO_Crypto::get_option( 'wpdo_legacy_discord' ) );
// Plaintext untouched.
$this->assertSame( 'plaintext', WPDO_Crypto::format_version( 'wpdo_plaintext_secret' ) );
// Unrelated option untouched.
global $wpdb;
$unrelated_value = $wpdb->get_var(
$wpdb->prepare(
"SELECT option_value FROM `" . self::TEST_PREFIX . "options` WHERE option_name = %s",
'unrelated_secret'
)
);
$this->assertSame( 'should be ignored', $unrelated_value );
}
public function test_migrate_idempotent_second_run_is_noop(): void {
$plain = 'a value';
$this->insert_v1_option( 'wpdo_test_idempotent', $plain );
$first = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' );
$second = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' );
// First run migrates 1, second run sees it as already_v2.
$this->assertSame( 1, $first['migrated'] );
$this->assertSame( 0, $second['migrated'] );
$this->assertSame( 1, $second['already_v2'] );
// Value still decrypts correctly after both runs.
$this->assertSame( $plain, WPDO_Crypto::get_option( 'wpdo_test_idempotent' ) );
}
public function test_migrate_empty_set(): void {
$counts = WPDO_Crypto::migrate_v1_to_v2( 'nonexistent_prefix_' );
$this->assertSame( 0, $counts['scanned'] );
$this->assertSame( 0, $counts['migrated'] );
$this->assertSame( 0, $counts['failed'] );
}
public function test_migrate_preserves_value_semantics(): void {
// Realistic test: write a webhook-shaped string that includes URL chars
// + special padding to make sure no encoding artifacts surface.
$plain = 'https://hooks.slack.com/services/T01/B02/=+&%/special?chars=true';
$this->insert_v1_option( 'wpdo_realistic_webhook', $plain );
WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' );
$this->assertSame( $plain, WPDO_Crypto::get_option( 'wpdo_realistic_webhook' ) );
}
// ── Helpers ──────────────────────────────────────────────────────────────
/**
* Insert an option containing a hand-crafted v1 (CBC) ciphertext.
*/
private function insert_v1_option( string $name, string $plaintext ): void {
$key = substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', AUTH_KEY . SECURE_AUTH_SALT, true ), 0, 32 );
$iv = random_bytes( 16 );
$ct = openssl_encrypt( $plaintext, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
$blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
$this->set_option_raw( $name, $blob );
}
/**
* Write a raw option value directly (bypasses WPDO_Crypto::set_option).
*/
private function set_option_raw( string $name, string $value ): void {
global $wpdb;
$wpdb->query(
$wpdb->prepare(
'REPLACE INTO `' . self::TEST_PREFIX . 'options` (option_name, option_value, autoload) VALUES (%s, %s, %s)',
$name,
$value,
'no'
)
);
$GLOBALS['_wp_options'][ $name ] = $value;
}
}
+268
View File
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* End-to-end test for WPDO_Demo_Entity_Counter — proves the entity adapter
* framework is not just a stub but actually delivers the full lifecycle:
*
* 1. Schema install (custom table + composite UNIQUE + secondary index)
* 2. Dual-write via WPDO_DB::upsert (1 RT)
* 3. Read routing via Feature_Flags FSM (idle → cutover → cleanup → complete)
* 4. Top-N query (the killer use case postmeta cannot do efficiently)
*
* Tests user / term / comment entities to validate cross-entity coverage.
*
* @covers WPDO_Demo_Entity_Counter
*/
class DemoEntityCounterTest extends TestCase {
public static function setUpBeforeClass(): void {
WPDO_Demo_Entity_Counter::drop_table();
WPDO_Demo_Entity_Counter::install_table();
}
public static function tearDownAfterClass(): void {
WPDO_Demo_Entity_Counter::drop_table();
WPDO_Feature_Flags::reset( WPDO_Demo_Entity_Counter::MODULE );
}
protected function setUp(): void {
// Reset feature flag module to idle before each test.
WPDO_Feature_Flags::reset( WPDO_Demo_Entity_Counter::MODULE );
// Truncate the table for clean state.
global $wpdb;
$wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "`" );
// Reset native usermeta global stubs (when running under integration env).
$GLOBALS['_wp_usermeta'] = array();
}
// ── Schema ─────────────────────────────────────────────────────────────
public function test_install_table_creates_with_composite_unique(): void {
global $wpdb;
$table = $wpdb->prefix . WPDO_Demo_Entity_Counter::TABLE;
// Index check: ui_entity_counter must be UNIQUE on (entity_type, entity_id, counter_key).
$rows = $wpdb->get_results(
$wpdb->prepare(
'SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s ORDER BY INDEX_NAME, SEQ_IN_INDEX',
$table
),
ARRAY_A
);
$ui_cols = array();
foreach ( $rows as $r ) {
if ( 'ui_entity_counter' === $r['INDEX_NAME'] && '0' === (string) $r['NON_UNIQUE'] ) {
$ui_cols[] = $r['COLUMN_NAME'];
}
}
$this->assertSame( array( 'entity_type', 'entity_id', 'counter_key' ), $ui_cols );
}
// ── set() / get() — idle state (native fallback only) ──────────────────
public function test_set_writes_to_native_meta_in_idle_state(): void {
WPDO_Demo_Entity_Counter::set( 'user', 100, 'points', 50 );
$this->assertSame( 50, (int) get_user_meta( 100, 'points', true ) );
}
public function test_get_reads_native_in_idle_state(): void {
update_user_meta( 200, 'points', 75 );
$this->assertSame( 75, WPDO_Demo_Entity_Counter::get( 'user', 200, 'points' ) );
}
public function test_idle_state_does_not_dual_write(): void {
WPDO_Demo_Entity_Counter::set( 'user', 300, 'points', 99 );
global $wpdb;
$count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d",
'user',
300
)
);
$this->assertSame( 0, $count, 'idle state must NOT dual-write to demo table' );
}
// ── set() — dual_write state ────────────────────────────────────────────
public function test_dual_write_state_writes_to_both(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'user', 400, 'points', 123 );
global $wpdb;
$zone_value = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
'user',
400,
'points'
)
);
$this->assertSame( 123, $zone_value, 'dual_write must populate the zone table' );
$this->assertSame( 123, (int) get_user_meta( 400, 'points', true ), 'dual_write must also keep native meta' );
}
public function test_upsert_uses_single_round_trip(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
// Two rapid writes to the same key — should produce exactly 1 row, not 2.
WPDO_Demo_Entity_Counter::set( 'user', 500, 'points', 10 );
WPDO_Demo_Entity_Counter::set( 'user', 500, 'points', 25 );
global $wpdb;
$rows = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d",
'user',
500
)
);
$this->assertSame( '1', (string) $rows, 'composite UNIQUE must collapse to 1 row' );
$value = $wpdb->get_var(
$wpdb->prepare(
"SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
'user',
500,
'points'
)
);
$this->assertSame( '25', (string) $value, 'second write must overwrite via UPSERT' );
}
// ── get() — cutover state (read from zone) ─────────────────────────────
public function test_cutover_state_reads_from_zone_table(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'user', 600, 'points', 999 );
// Switch to cutover — reads now come from zone.
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'cutover' );
// Tamper with native meta to prove zone table is the source of truth.
update_user_meta( 600, 'points', 0 );
$this->assertSame( 999, WPDO_Demo_Entity_Counter::get( 'user', 600, 'points' ) );
}
public function test_cutover_falls_back_to_native_when_zone_row_missing(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'cutover' );
// No dual_write history — zone table is empty for this entity.
update_user_meta( 700, 'points', 42 );
$this->assertSame( 42, WPDO_Demo_Entity_Counter::get( 'user', 700, 'points' ), 'graceful fallback when zone row absent' );
}
// ── Cross-entity coverage ──────────────────────────────────────────────
public function test_term_entity_works(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'term', 800, 'usage_count', 17 );
global $wpdb;
$value = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
'term',
800,
'usage_count'
)
);
$this->assertSame( 17, $value );
}
public function test_comment_entity_works(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'comment', 900, 'helpful_count', 8 );
global $wpdb;
$value = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
'comment',
900,
'helpful_count'
)
);
$this->assertSame( 8, $value );
}
public function test_invalid_entity_type_returns_false(): void {
$this->assertFalse( WPDO_Demo_Entity_Counter::set( 'bogus', 1, 'k', 1 ) );
$this->assertSame( 0, WPDO_Demo_Entity_Counter::get( 'bogus', 1, 'k' ) );
}
// ── Top-N query (killer use case postmeta can't do efficiently) ───────
public function test_top_n_query_returns_sorted_results(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
// Seed 5 users with varying point counts.
WPDO_Demo_Entity_Counter::set( 'user', 1001, 'points', 100 );
WPDO_Demo_Entity_Counter::set( 'user', 1002, 'points', 500 );
WPDO_Demo_Entity_Counter::set( 'user', 1003, 'points', 200 );
WPDO_Demo_Entity_Counter::set( 'user', 1004, 'points', 800 );
WPDO_Demo_Entity_Counter::set( 'user', 1005, 'points', 350 );
$top3 = WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 3 );
$this->assertCount( 3, $top3 );
// Sorted DESC: 1004(800) > 1002(500) > 1005(350) > 1003(200) > 1001(100)
$this->assertSame( 1004, $top3[0]['entity_id'] );
$this->assertSame( 800, $top3[0]['counter_value'] );
$this->assertSame( 1002, $top3[1]['entity_id'] );
$this->assertSame( 500, $top3[1]['counter_value'] );
$this->assertSame( 1005, $top3[2]['entity_id'] );
$this->assertSame( 350, $top3[2]['counter_value'] );
}
public function test_top_n_filters_by_entity_type(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
WPDO_Demo_Entity_Counter::set( 'user', 2001, 'points', 999 );
WPDO_Demo_Entity_Counter::set( 'term', 2001, 'usage_count', 999 ); // same id, different type.
$users = WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 10 );
$terms = WPDO_Demo_Entity_Counter::top_n( 'term', 'usage_count', 10 );
$this->assertCount( 1, $users );
$this->assertCount( 1, $terms );
$this->assertSame( 2001, $users[0]['entity_id'] );
$this->assertSame( 2001, $terms[0]['entity_id'] );
}
// ── Mini benchmark — proves zone table beats postmeta on top-N ────────
public function test_benchmark_top_n_zone_vs_postmeta_simulated(): void {
WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' );
// Seed 100 users with random point values.
for ( $i = 3001; $i <= 3100; $i++ ) {
WPDO_Demo_Entity_Counter::set( 'user', $i, 'points', wp_rand( 0, 10000 ) );
}
// Time the zone-table top-10 query.
$t1 = microtime( true );
for ( $i = 0; $i < 100; $i++ ) {
WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 10 );
}
$zone_ms = ( microtime( true ) - $t1 ) * 1000;
// We expect 100 zone reads under 200ms total (well under "1 LEFT JOIN per request").
$this->assertLessThan(
500,
$zone_ms,
"100 top-N reads from zone table took {$zone_ms}ms — exceeded 500ms ceiling"
);
// Print for visibility (PHPUnit captures to test output, no assertion impact).
fwrite( STDOUT, "\n Demo benchmark: 100x top-10 in {$zone_ms}ms (avg " . round( $zone_ms / 100, 2 ) . "ms/call)\n" );
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Installer multisite cleanup (v2.14.0).
*
* Verifies the new `drop_all_tables_for_current_blog()` shared helper used
* by both `uninstall.php` and the `wp_uninitialize_site` hook handler.
*/
class InstallerCleanupTest extends TestCase {
// v2.14.0: dedicated prefix to avoid colliding with shared `wp_itest_*`
// fixtures created by other test classes. The cleanup helper uses
// `$wpdb->prefix` so changing the prefix scopes drops to our tables only.
private const TEST_PREFIX = 'wp_clnup_';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Installer' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-installer.php';
}
$wpdb->prefix = self::TEST_PREFIX;
$wpdb->options = self::TEST_PREFIX . 'options';
// Ensure options table exists (needed for delete_option fallback path).
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::TEST_PREFIX . 'options` (
option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
option_name varchar(191) NOT NULL DEFAULT "",
option_value longtext NOT NULL,
autoload varchar(20) NOT NULL DEFAULT "yes",
PRIMARY KEY (option_id),
UNIQUE KEY option_name (option_name)
) DEFAULT CHARACTER SET utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
// Drop the dedicated options table + any residual prefix tables.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TEST_PREFIX . 'options`' );
foreach ( array(
self::TEST_PREFIX . 'wpdo_archive',
self::TEST_PREFIX . 'wpdo_warm',
self::TEST_PREFIX . 'wpdo_errors',
self::TEST_PREFIX . 'wpdo_hot_test_type',
self::TEST_PREFIX . 'wpdo_user_profile',
self::TEST_PREFIX . 'wpdo_post_attachment',
self::TEST_PREFIX . 'wpdo_term_hp_taxonomy',
self::TEST_PREFIX . 'wpdo_comment_hp_review',
self::TEST_PREFIX . 'unrelated_table',
) as $tbl ) {
$wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' );
}
// Restore the shared integration test prefix so any teardown elsewhere
// that depends on `$wpdb->prefix === 'wp_itest_'` still works.
$wpdb->prefix = 'wp_itest_';
}
protected function setUp(): void {
global $wpdb;
// Reset options table state.
$wpdb->query( 'TRUNCATE TABLE `' . self::TEST_PREFIX . 'options`' );
$GLOBALS['_wp_options'] = array();
}
public function test_drops_static_tables(): void {
global $wpdb;
// Create a few WPDO-prefixed tables that should be dropped.
$wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_archive` (id INT)' );
$wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_warm` (id INT)' );
$counts = WPDO_Installer::drop_all_tables_for_current_blog();
$this->assertGreaterThanOrEqual( 2, $counts['tables_dropped'] );
$exists = (int) $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
self::TEST_PREFIX . 'wpdo_archive'
)
);
$this->assertSame( 0, $exists, 'wpdo_archive should be dropped' );
}
public function test_drops_dynamic_zone_tables(): void {
global $wpdb;
// Dynamic hot/cold zone tables should be discovered via LIKE pattern.
$wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_hot_test_type` (id INT)' );
$wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_user_profile` (id INT)' );
$wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_post_attachment` (id INT)' );
$wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_term_hp_taxonomy` (id INT)' );
$wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_comment_hp_review` (id INT)' );
WPDO_Installer::drop_all_tables_for_current_blog();
foreach ( array(
'wpdo_hot_test_type',
'wpdo_user_profile',
'wpdo_post_attachment',
'wpdo_term_hp_taxonomy',
'wpdo_comment_hp_review',
) as $tbl_suffix ) {
$exists = (int) $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
self::TEST_PREFIX . $tbl_suffix
)
);
$this->assertSame( 0, $exists, $tbl_suffix . ' should be dropped' );
}
}
public function test_does_not_drop_unrelated_tables(): void {
global $wpdb;
// Defensive: a table named like wpdo_X should be dropped, but a table
// with a non-wpdo prefix MUST NEVER be dropped even if name pattern
// would match.
$unrelated = self::TEST_PREFIX . 'unrelated_table';
$wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . $unrelated . '` (id INT)' );
WPDO_Installer::drop_all_tables_for_current_blog();
$exists = (int) $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$unrelated
)
);
$this->assertSame( 1, $exists, 'Non-wpdo table must not be dropped' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . $unrelated . '`' );
}
public function test_returns_counts_structure(): void {
$counts = WPDO_Installer::drop_all_tables_for_current_blog();
$this->assertIsArray( $counts );
$this->assertArrayHasKey( 'tables_dropped', $counts );
$this->assertArrayHasKey( 'options_deleted', $counts );
$this->assertArrayHasKey( 'crons_cleared', $counts );
}
public function test_idempotent_on_empty_state(): void {
// Run twice — second run should be a no-op for tables (we already
// dropped them all in the first run). Options may not be 0 because
// other test classes share the same wp_itest_options table and may
// continually re-create wpdo_* rows; just verify that running cleanup
// twice in a row does not throw.
WPDO_Installer::drop_all_tables_for_current_blog();
$second = WPDO_Installer::drop_all_tables_for_current_blog();
$this->assertSame( 0, $second['tables_dropped'] );
$this->assertIsInt( $second['options_deleted'] );
$this->assertIsInt( $second['crons_cleared'] );
}
public function test_drops_known_options(): void {
// Stub `delete_option` does not interact with DB layer in our test stubs;
// it modifies `$GLOBALS['_wp_options']`. Verify counts work via the
// known-options list.
$GLOBALS['_wp_options']['wpdo_db_version'] = '2.14.0';
$GLOBALS['_wp_options']['wpdo_features'] = array();
$GLOBALS['_wp_options']['wpdo_health_alert'] = '1';
$counts = WPDO_Installer::drop_all_tables_for_current_blog();
// Note: the actual count depends on $wpdb->options interaction in
// the residual sweep. Just verify the structure works without errors.
$this->assertIsInt( $counts['options_deleted'] );
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test for WPDO_Installer::install_v2_tables() — PR-2 v2.0.0 schema.
*
* Verifies idempotent table creation for:
* - wp_*_wpdo_audit
* - wp_*_wpdo_shadow_diffs
* - wp_*_wpdo_site_metrics
* - wp_*_wpdo_uni_options
*
* @covers WPDO_Installer::install_v2_tables
* @covers WPDO_Installer::v2_tables_status
*/
class InstallerV2Test extends TestCase {
public static function setUpBeforeClass(): void {
// Drop v2 tables for a clean slate.
global $wpdb;
$p = $wpdb->prefix;
$wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_audit`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_shadow_diffs`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_site_metrics`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_uni_options`" );
}
public function test_v2_tables_initially_absent(): void {
$status = WPDO_Installer::v2_tables_status();
foreach ( $status as $table => $exists ) {
$this->assertFalse( $exists, "Expected {$table} to NOT exist initially" );
}
}
public function test_install_v2_tables_creates_all_four(): void {
WPDO_Installer::install_v2_tables();
$status = WPDO_Installer::v2_tables_status();
foreach ( $status as $table => $exists ) {
$this->assertTrue( $exists, "Expected {$table} to exist after install_v2_tables()" );
}
}
public function test_install_v2_tables_is_idempotent(): void {
WPDO_Installer::install_v2_tables();
WPDO_Installer::install_v2_tables(); // Second call must not error.
WPDO_Installer::install_v2_tables(); // Third for good measure.
$status = WPDO_Installer::v2_tables_status();
$this->assertCount( 4, $status );
$this->assertTrue( array_reduce( $status, static fn( $carry, $v ) => $carry && $v, true ) );
}
public function test_audit_table_has_required_columns(): void {
global $wpdb;
$p = $wpdb->prefix;
$cols = $wpdb->get_col( "SHOW COLUMNS FROM `{$p}wpdo_audit`" );
// PR-2 spec: op, value_before, value_after, source, trace_id are required.
foreach ( array( 'op', 'value_before', 'value_after', 'source', 'trace_id' ) as $required ) {
$this->assertContains( $required, $cols, "wpdo_audit missing column {$required}" );
}
}
public function test_shadow_diffs_table_has_required_columns(): void {
global $wpdb;
$p = $wpdb->prefix;
$cols = $wpdb->get_col( "SHOW COLUMNS FROM `{$p}wpdo_shadow_diffs`" );
foreach ( array( 'entity_type', 'entity_id', 'meta_key', 'postmeta_value', 'zone_value', 'diff_hash' ) as $required ) {
$this->assertContains( $required, $cols, "wpdo_shadow_diffs missing column {$required}" );
}
}
public function test_uni_options_has_unique_index_on_option_name(): void {
global $wpdb;
$p = $wpdb->prefix;
$indexes = $wpdb->get_results(
$wpdb->prepare(
'SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$p . 'wpdo_uni_options'
),
ARRAY_A
);
$found_unique = false;
foreach ( $indexes as $idx ) {
if ( 'option_name' === $idx['COLUMN_NAME'] && '0' === (string) $idx['NON_UNIQUE'] ) {
$found_unique = true;
break;
}
}
$this->assertTrue( $found_unique, 'Expected UNIQUE index on wpdo_uni_options.option_name' );
}
public function test_audit_table_indexes_for_query_performance(): void {
global $wpdb;
$p = $wpdb->prefix;
$indexes = $wpdb->get_col(
$wpdb->prepare(
'SELECT DISTINCT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$p . 'wpdo_audit'
)
);
// Performance-critical indexes per Part F.3 schema spec.
foreach ( array( 'idx_entity', 'idx_meta_key', 'idx_ts', 'idx_trace' ) as $required ) {
$this->assertContains( $required, $indexes, "wpdo_audit missing index {$required}" );
}
}
public static function tearDownAfterClass(): void {
// Leave v2 tables in place for subsequent tests / dev convenience.
// Cleanup happens via wp wpdo cleanup-uae-tables --confirm in real upgrades.
}
}
@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: wp_usermeta → wp_wpdo_user_membership backfill.
*
* Creates isolated tables (wp_itest_usermeta, wp_itest_wpdo_user_membership,
* wp_itest_wpdo_migration_status) and verifies that
* WPDO_Entity_Migration_Engine::migrate_group('user', 'membership') reads the
* EAV rows and produces correct flat-table rows.
*
* Requires real MariaDB (WPDO_TEST_DB_PASS env var must be set).
*/
class MemberBackfillIntegrationTest extends TestCase {
private const MEM_TABLE = 'wp_itest_wpdo_user_membership';
private const STATUS_TABLE = 'wp_itest_wpdo_migration_status';
private const USERMETA = 'wp_itest_usermeta';
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
self::load_engine_classes();
self::create_tables();
self::register_user_entity();
}
public static function tearDownAfterClass(): void {
global $wpdb;
foreach ( array( self::MEM_TABLE, self::STATUS_TABLE, self::USERMETA ) as $t ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$t}`" );
}
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::MEM_TABLE . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::STATUS_TABLE . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::USERMETA . '`' );
}
// ── Tests ─────────────────────────────────────────────────────────────────
public function test_migrates_membership_level_and_points(): void {
$this->seed_usermeta( array(
array( 'user_id' => 1, 'meta_key' => 'membership_level', 'meta_value' => 'gold' ),
array( 'user_id' => 1, 'meta_key' => 'points_balance', 'meta_value' => '500' ),
array( 'user_id' => 2, 'meta_key' => 'membership_level', 'meta_value' => 'silver' ),
array( 'user_id' => 2, 'meta_key' => 'points_balance', 'meta_value' => '200' ),
) );
$result = WPDO_Entity_Migration_Engine::migrate_group( 'user', 'membership', array( 'sleep_ms' => 0 ) );
$this->assertSame( 2, $result['migrated'], 'Expected 2 migrated rows' );
$this->assertSame( 0, $result['errors'] );
global $wpdb;
$row1 = $wpdb->get_row( "SELECT * FROM `" . self::MEM_TABLE . "` WHERE user_id = 1", ARRAY_A );
$this->assertNotNull( $row1 );
$this->assertSame( 'gold', $row1['membership_level'] );
$this->assertSame( '500', $row1['points_balance'] );
$row2 = $wpdb->get_row( "SELECT * FROM `" . self::MEM_TABLE . "` WHERE user_id = 2", ARRAY_A );
$this->assertNotNull( $row2 );
$this->assertSame( 'silver', $row2['membership_level'] );
$this->assertSame( '200', $row2['points_balance'] );
}
public function test_skips_users_with_no_managed_keys(): void {
$this->seed_usermeta( array(
array( 'user_id' => 3, 'meta_key' => 'some_other_meta', 'meta_value' => 'value' ),
) );
$result = WPDO_Entity_Migration_Engine::migrate_group( 'user', 'membership', array( 'sleep_ms' => 0 ) );
$this->assertSame( 0, $result['migrated'] );
$this->assertSame( 0, $result['errors'] );
}
public function test_dry_run_does_not_write_to_flat_table(): void {
$this->seed_usermeta( array(
array( 'user_id' => 4, 'meta_key' => 'membership_level', 'meta_value' => 'platinum' ),
) );
$result = WPDO_Entity_Migration_Engine::migrate_group(
'user', 'membership',
array( 'sleep_ms' => 0, 'dry_run' => true )
);
$this->assertTrue( $result['dry_run'] );
$this->assertSame( 1, $result['migrated'] );
global $wpdb;
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::MEM_TABLE . "`" );
$this->assertSame( 0, $count, 'Dry run must not write to flat table' );
}
public function test_row_count_matches_seeded_users(): void {
$this->seed_usermeta( array(
array( 'user_id' => 10, 'meta_key' => 'membership_level', 'meta_value' => 'bronze' ),
array( 'user_id' => 11, 'meta_key' => 'membership_level', 'meta_value' => 'bronze' ),
array( 'user_id' => 12, 'meta_key' => 'points_balance', 'meta_value' => '50' ),
) );
$result = WPDO_Entity_Migration_Engine::migrate_group( 'user', 'membership', array( 'sleep_ms' => 0 ) );
// user_id 10, 11 have membership_level; user_id 12 has points_balance.
$this->assertSame( 3, $result['migrated'] );
global $wpdb;
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::MEM_TABLE . "`" );
$this->assertSame( 3, $count );
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function seed_usermeta( array $rows ): void {
global $wpdb;
foreach ( $rows as $row ) {
$wpdb->insert( self::USERMETA, $row );
}
}
private static function load_engine_classes(): void {
$base = WPDO_PLUGIN_DIR;
$files = array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/engine/class-tmdo-entity-migration-engine.php',
);
foreach ( $files as $file ) {
if ( ! class_exists( self::class_for_file( $file ) ) ) {
require_once $base . $file;
}
}
}
private static function class_for_file( string $file ): string {
$map = array(
'interface-entity-adapter.php' => 'WPDO_Entity_Adapter_Interface',
'class-tmdo-type-caster.php' => 'WPDO_Type_Caster',
'class-tmdo-schema-manager.php' => 'WPDO_Schema_Manager',
'class-tmdo-entity-registry.php' => 'WPDO_Entity_Registry',
'class-tmdo-adapter-user.php' => 'WPDO_Adapter_User',
'class-tmdo-entity-migration-engine.php' => 'WPDO_Entity_Migration_Engine',
);
return $map[ basename( $file ) ] ?? '';
}
private static function create_tables(): void {
global $wpdb;
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::USERMETA . '` (
`umeta_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`meta_key` varchar(255) DEFAULT NULL,
`meta_value` longtext DEFAULT NULL,
PRIMARY KEY (`umeta_id`),
KEY `user_id` (`user_id`),
KEY `meta_key` (`meta_key`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::MEM_TABLE . '` (
`user_id` bigint(20) NOT NULL,
`membership_level` varchar(100) DEFAULT NULL,
`points_balance` bigint(20) DEFAULT 0,
`expires_at` datetime DEFAULT NULL,
`activated_at` datetime DEFAULT NULL,
`tier_source` varchar(255) DEFAULT NULL,
`custom_tier` varchar(255) DEFAULT NULL,
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::STATUS_TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`entity_type` varchar(50) NOT NULL,
`group_name` varchar(50) NOT NULL,
`last_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`total_migrated` bigint(20) unsigned NOT NULL DEFAULT 0,
`status` varchar(20) NOT NULL DEFAULT \'pending\',
`started_at` datetime DEFAULT NULL,
`completed_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `entity_group` (`entity_type`, `group_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
private static function register_user_entity(): void {
if ( ! class_exists( 'WPDO_Entity_Registry' ) ) {
return;
}
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group(
'user',
'membership',
array(
array(
'key' => 'membership_level',
'type' => 'enum',
'searchable' => true,
'options' => array( 'bronze', 'silver', 'gold', 'platinum', 'custom' ),
),
array( 'key' => 'points_balance', 'type' => 'integer', 'searchable' => true, 'default' => 0 ),
array( 'key' => 'expires_at', 'type' => 'datetime', 'searchable' => true ),
array( 'key' => 'activated_at', 'type' => 'datetime' ),
array( 'key' => 'tier_source', 'type' => 'text' ),
array( 'key' => 'custom_tier', 'type' => 'text' ),
)
);
}
}
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Migration_Orchestrator core SQL paths.
*
* Focuses on the parts that cannot be mocked at unit-test level:
* - Bulk SQL pivot (INSERT...SELECT...GROUP BY...ON DUPLICATE KEY UPDATE)
* - Idempotency (re-running pivot must not lose data, must not double-count)
* - Lock acquisition
* - Managed-key list correctness
*
* The orchestrator's full state-machine flow is exercised live on the dev
* environment (see PLAN.md / W-6 smoke-test); this test covers the
* deterministic SQL transforms that are easiest to regress.
*
* Requires real MariaDB (WPDO_TEST_DB_PASS env var must be set).
*/
final class MigrationOrchestratorTest extends TestCase {
private const USERMETA = 'wp_itest_usermeta';
private const FLAT = 'wp_itest_wpdo_user_core_profile';
public static function setUpBeforeClass(): void {
global $wpdb;
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/engine/class-tmdo-entity-migration-engine.php',
) as $f ) {
require_once $base . $f;
}
// Drop + recreate to guarantee clean schema.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::USERMETA . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
$wpdb->query(
'CREATE TABLE `' . self::USERMETA . '` (
`umeta_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`meta_key` varchar(255) DEFAULT NULL,
`meta_value` longtext DEFAULT NULL,
PRIMARY KEY (`umeta_id`),
KEY `meta_key` (`meta_key`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$wpdb->query(
'CREATE TABLE `' . self::FLAT . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL,
`nickname` varchar(255) DEFAULT NULL,
`first_name` varchar(255) DEFAULT NULL,
`last_name` varchar(255) DEFAULT NULL,
`description` text DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::USERMETA . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
}
public function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE `' . self::USERMETA . '`' );
$wpdb->query( 'TRUNCATE `' . self::FLAT . '`' );
}
// ── Tests ────────────────────────────────────────────────────────────────
public function test_bulk_pivot_produces_one_row_per_user(): void {
$this->seed_eav( array(
array( 'user_id' => 10, 'meta_key' => 'first_name', 'meta_value' => 'Alice' ),
array( 'user_id' => 10, 'meta_key' => 'last_name', 'meta_value' => 'Adams' ),
array( 'user_id' => 10, 'meta_key' => 'nickname', 'meta_value' => 'al' ),
array( 'user_id' => 11, 'meta_key' => 'first_name', 'meta_value' => 'Bob' ),
array( 'user_id' => 11, 'meta_key' => 'description', 'meta_value' => 'engineer' ),
) );
$affected = $this->run_pivot();
// MySQL returns 2*N for INSERT...ON DUPLICATE on conflict, N for new inserts.
// Two new users → both INSERTs → affected_rows == 2.
$this->assertSame( 2, $affected );
global $wpdb;
$row10 = $wpdb->get_row( 'SELECT * FROM `' . self::FLAT . '` WHERE user_id=10', ARRAY_A );
$row11 = $wpdb->get_row( 'SELECT * FROM `' . self::FLAT . '` WHERE user_id=11', ARRAY_A );
$this->assertSame( 'Alice', $row10['first_name'] );
$this->assertSame( 'Adams', $row10['last_name'] );
$this->assertSame( 'al', $row10['nickname'] );
$this->assertNull( $row10['description'] );
$this->assertSame( 'Bob', $row11['first_name'] );
$this->assertSame( 'engineer', $row11['description'] );
$this->assertNull( $row11['last_name'] );
}
public function test_bulk_pivot_idempotent_re_run_preserves_data(): void {
$this->seed_eav( array(
array( 'user_id' => 20, 'meta_key' => 'first_name', 'meta_value' => 'Carol' ),
) );
$this->run_pivot();
$this->run_pivot(); // Second run must not lose or double-count data.
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' );
$this->assertSame( 1, $count, 'Re-run should not duplicate user_id row' );
$first = $wpdb->get_var( 'SELECT first_name FROM `' . self::FLAT . '` WHERE user_id=20' );
$this->assertSame( 'Carol', $first );
}
public function test_bulk_pivot_coalesce_preserves_existing_when_new_eav_subset(): void {
// Round 1: full data.
$this->seed_eav( array(
array( 'user_id' => 30, 'meta_key' => 'first_name', 'meta_value' => 'Dora' ),
array( 'user_id' => 30, 'meta_key' => 'last_name', 'meta_value' => 'Diaz' ),
) );
$this->run_pivot();
// Round 2: only first_name remains in EAV (last_name was cleaned).
global $wpdb;
$wpdb->query( "DELETE FROM `" . self::USERMETA . "` WHERE meta_key='last_name'" );
$this->run_pivot();
$row = $wpdb->get_row( 'SELECT * FROM `' . self::FLAT . '` WHERE user_id=30', ARRAY_A );
// COALESCE(VALUES(last_name), last_name) → keeps 'Diaz' even though new VALUES is NULL.
$this->assertSame( 'Dora', $row['first_name'] );
$this->assertSame( 'Diaz', $row['last_name'], 'COALESCE should preserve previously-migrated value when EAV is now empty' );
}
public function test_bulk_pivot_handles_empty_eav_gracefully(): void {
$affected = $this->run_pivot();
$this->assertSame( 0, $affected );
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' );
$this->assertSame( 0, $count );
}
public function test_bulk_pivot_uses_max_for_duplicate_meta_keys(): void {
// HivePress occasionally writes duplicate meta_value rows for the same key.
$this->seed_eav( array(
array( 'user_id' => 40, 'meta_key' => 'first_name', 'meta_value' => 'older_value' ),
array( 'user_id' => 40, 'meta_key' => 'first_name', 'meta_value' => 'newer_value' ),
) );
$this->run_pivot();
global $wpdb;
$first = $wpdb->get_var( 'SELECT first_name FROM `' . self::FLAT . '` WHERE user_id=40' );
// MAX() picks lexicographically larger; for our purpose this just guarantees
// deterministic behavior — no NULL, no error.
$this->assertNotNull( $first );
$this->assertContains( $first, array( 'older_value', 'newer_value' ) );
}
// ── Helpers ──────────────────────────────────────────────────────────────
private function seed_eav( array $rows ): void {
global $wpdb;
foreach ( $rows as $row ) {
$wpdb->insert( self::USERMETA, $row );
}
}
/**
* Local mirror of WPDO_Migration_Orchestrator::execute_bulk_pivot() against
* isolated test tables. Builds the same SQL form but pointing at our test
* usermeta and flat tables (the orchestrator targets $wpdb->usermeta).
*/
private function run_pivot(): int {
global $wpdb;
$keys = array( 'nickname', 'first_name', 'last_name', 'description' );
$cols = $keys;
$ph = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
$cases = array();
$updates = array();
foreach ( $cols as $col ) {
$cases[] = "MAX(CASE WHEN um.meta_key = '{$col}' THEN um.meta_value END) AS `{$col}`";
$updates[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)";
}
$sql = sprintf(
'INSERT INTO `%s` (`user_id`, %s)
SELECT um.user_id, %s
FROM `%s` um
WHERE um.meta_key IN (%s)
GROUP BY um.user_id
ON DUPLICATE KEY UPDATE %s',
self::FLAT,
implode( ', ', array_map( fn( $c ) => "`{$c}`", $cols ) ),
implode( ', ', $cases ),
self::USERMETA,
$ph,
implode( ', ', $updates )
);
$result = $wpdb->query( $wpdb->prepare( $sql, ...$keys ) );
return false === $result ? 0 : (int) $result;
}
}
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Points_Manager against real MariaDB.
*
* Verifies transaction discipline (BEGIN/COMMIT/ROLLBACK), atomic balance
* serialisation, ledger integrity, and overdraft protection using the real
* InnoDB transaction engine.
*
* Tables created with wp_itest_ prefix to avoid polluting production data.
*/
class PointsAtomicIntegrationTest extends TestCase {
private const MEM_TABLE = 'wp_itest_wpdo_user_membership';
private const LEDGER_TABLE = 'wp_itest_wpdo_user_points_ledger';
private const ERRORS_TABLE = 'wp_itest_wpdo_errors';
// ── Fixture lifecycle ────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Points_Manager' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-points-manager.php';
}
if ( ! class_exists( 'WPDO_DB' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-db.php';
}
if ( ! class_exists( 'WPDO_Logger' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-logger.php';
}
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::MEM_TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL,
`points_balance` bigint(20) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::LEDGER_TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL,
`delta` int(11) NOT NULL,
`balance_after` bigint(20) NOT NULL,
`reason` varchar(60) NOT NULL DEFAULT \'\',
`ref_id` bigint(20) DEFAULT NULL,
`ref_type` varchar(30) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_created` (`user_id`,`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::ERRORS_TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`severity` varchar(10) NOT NULL DEFAULT \'error\',
`component` varchar(60) NOT NULL DEFAULT \'\',
`context` varchar(60) NOT NULL DEFAULT \'\',
`message` text NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::MEM_TABLE . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::LEDGER_TABLE . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::ERRORS_TABLE . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::MEM_TABLE . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::LEDGER_TABLE . '`' );
}
// ── credit() happy path ─────────────────────────────────────────────────
public function test_credit_creates_membership_row(): void {
$result = WPDO_Points_Manager::credit( 1, 100, 'signup_bonus' );
$this->assertTrue( $result['ok'] );
$this->assertSame( 100, $result['balance'] );
$this->assertGreaterThan( 0, $result['ledger_id'] );
}
public function test_credit_accumulates_balance(): void {
WPDO_Points_Manager::credit( 2, 200, 'first' );
$result = WPDO_Points_Manager::credit( 2, 300, 'second' );
$this->assertTrue( $result['ok'] );
$this->assertSame( 500, $result['balance'] );
}
public function test_credit_writes_ledger_row(): void {
global $wpdb;
WPDO_Points_Manager::credit( 3, 50, 'test_reason' );
$row = $wpdb->get_row(
"SELECT * FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 3",
ARRAY_A
);
$this->assertNotNull( $row );
$this->assertSame( '50', $row['delta'] );
$this->assertSame( '50', $row['balance_after'] );
$this->assertSame( 'test_reason', $row['reason'] );
}
public function test_get_balance_reflects_credits(): void {
WPDO_Points_Manager::credit( 4, 75, 'top_up' );
$balance = WPDO_Points_Manager::get_balance( 4 );
$this->assertSame( 75, $balance );
}
// ── debit() happy path ──────────────────────────────────────────────────
public function test_debit_after_credit_reduces_balance(): void {
WPDO_Points_Manager::credit( 5, 300, 'load' );
$result = WPDO_Points_Manager::debit( 5, 100, 'purchase' );
$this->assertTrue( $result['ok'] );
$this->assertSame( 200, $result['balance'] );
}
public function test_debit_writes_negative_delta_to_ledger(): void {
global $wpdb;
WPDO_Points_Manager::credit( 6, 200, 'load' );
WPDO_Points_Manager::debit( 6, 50, 'spend' );
$rows = $wpdb->get_results(
"SELECT delta, balance_after FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 6 ORDER BY id",
ARRAY_A
);
$this->assertCount( 2, $rows );
$this->assertSame( '200', $rows[0]['delta'] ); // credit row.
$this->assertSame( '-50', $rows[1]['delta'] );
$this->assertSame( '150', $rows[1]['balance_after'] );
}
// ── debit() insufficient balance ─────────────────────────────────────────
public function test_debit_fails_when_insufficient(): void {
WPDO_Points_Manager::credit( 7, 50, 'load' );
$result = WPDO_Points_Manager::debit( 7, 100, 'purchase' );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'insufficient_balance', $result['error'] );
}
public function test_debit_failure_does_not_write_ledger(): void {
global $wpdb;
WPDO_Points_Manager::credit( 8, 30, 'load' );
WPDO_Points_Manager::debit( 8, 100, 'purchase' ); // should fail.
$count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 8 AND delta < 0"
);
$this->assertSame( 0, $count );
}
public function test_debit_failure_preserves_balance(): void {
WPDO_Points_Manager::credit( 9, 40, 'load' );
WPDO_Points_Manager::debit( 9, 200, 'purchase' ); // fails.
$balance = WPDO_Points_Manager::get_balance( 9 );
$this->assertSame( 40, $balance );
}
// ── sequential double-spend scenario ────────────────────────────────────
/**
* Simulate the classic double-spend race:
* Balance = 100. Two requests each try to debit 80.
* With FOR UPDATE serialisation: first succeeds → balance = 20,
* second then reads balance = 20 and correctly rejects (insufficient).
*/
public function test_sequential_debit_only_first_succeeds(): void {
WPDO_Points_Manager::credit( 10, 100, 'load' );
$first = WPDO_Points_Manager::debit( 10, 80, 'spend_1' );
$second = WPDO_Points_Manager::debit( 10, 80, 'spend_2' );
$this->assertTrue( $first['ok'], 'First debit should succeed' );
$this->assertSame( 20, $first['balance'] );
$this->assertFalse( $second['ok'], 'Second debit should fail (insufficient)' );
$this->assertSame( 'insufficient_balance', $second['error'] );
$this->assertSame( 20, WPDO_Points_Manager::get_balance( 10 ) );
}
public function test_sequential_debits_leave_correct_ledger_count(): void {
global $wpdb;
WPDO_Points_Manager::credit( 11, 200, 'load' );
WPDO_Points_Manager::debit( 11, 150, 'spend_1' ); // succeeds: balance=50.
WPDO_Points_Manager::debit( 11, 150, 'spend_2' ); // fails: insufficient.
$debit_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 11 AND delta < 0"
);
$this->assertSame( 1, $debit_count, 'Only one successful debit should be in ledger' );
}
// ── allow_overdraft ──────────────────────────────────────────────────────
public function test_overdraft_debit_goes_negative(): void {
WPDO_Points_Manager::credit( 12, 50, 'load' );
$result = WPDO_Points_Manager::debit( 12, 200, 'force', 0, '', true );
$this->assertTrue( $result['ok'] );
$this->assertSame( -150, $result['balance'] );
}
// ── ref_id / ref_type ────────────────────────────────────────────────────
public function test_credit_with_ref_id_and_type(): void {
global $wpdb;
WPDO_Points_Manager::credit( 13, 100, 'order_reward', 9999, 'order' );
$row = $wpdb->get_row(
"SELECT ref_id, ref_type FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 13",
ARRAY_A
);
$this->assertSame( '9999', $row['ref_id'] );
$this->assertSame( 'order', $row['ref_type'] );
}
// ── get_ledger() ─────────────────────────────────────────────────────────
public function test_get_ledger_returns_entries_newest_first(): void {
WPDO_Points_Manager::credit( 14, 100, 'a' );
WPDO_Points_Manager::credit( 14, 200, 'b' );
$ledger = WPDO_Points_Manager::get_ledger( 14 );
$this->assertCount( 2, $ledger );
// Newest-first: second credit (delta=200) should be first.
$this->assertSame( '200', $ledger[0]['delta'] );
$this->assertSame( '100', $ledger[1]['delta'] );
}
public function test_get_ledger_respects_limit(): void {
for ( $i = 1; $i <= 5; $i++ ) {
WPDO_Points_Manager::credit( 15, 10, "entry_{$i}" );
}
$ledger = WPDO_Points_Manager::get_ledger( 15, 3 );
$this->assertCount( 3, $ledger );
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Post_Migration::backfill_group_json() (v2.10.4).
*
* Validates row-by-row backfill for groups containing json-typed fields
* (attachment._wp_attachment_metadata, nav_menu_item._menu_item_classes,
* etc.) — these can't go through the bulk SQL pivot in backfill_group()
* because the values need PHP-level safe_unserialize → json_encode.
*
* Method is a thin wrapper over WPDO_Entity_Migration_Engine::migrate_group()
* which is already entity-agnostic; this test confirms the dispatch and
* sanity-checks output for post entity.
*/
class PostBackfillJsonTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const POSTMETA = 'wp_itest_postmeta';
private const FLAT = 'wp_itest_wpdo_post_attachment';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/interface-entity-adapter.php';
}
foreach ( array(
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-entity-migration-engine.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/integrations/class-tmdo-post-fields.php',
'includes/migration/class-tmdo-post-migration.php',
) as $rel ) {
$file = WPDO_PLUGIN_DIR . $rel;
if ( file_exists( $file ) ) {
require_once $file;
}
}
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTS . '` (
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_type varchar(20) NOT NULL DEFAULT \'post\',
PRIMARY KEY (ID),
KEY post_type (post_type)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY post_id (post_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
$wpdb->query(
'CREATE TABLE `' . self::FLAT . '` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
_wp_attached_file varchar(255) DEFAULT NULL,
_wp_attachment_metadata longtext DEFAULT NULL,
_wp_attachment_image_alt longtext DEFAULT NULL,
_wp_attachment_caption longtext DEFAULT NULL,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_post_id (post_id)
) DEFAULT CHARACTER SET utf8mb4'
);
// migration_status table needed by Entity_Migration_Engine
// (schema mirrors MemberBackfillIntegrationTest fixture).
$wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_migration_status`' );
$wpdb->query(
'CREATE TABLE `wp_itest_wpdo_migration_status` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
entity_type varchar(50) NOT NULL,
group_name varchar(50) NOT NULL,
last_id bigint(20) unsigned NOT NULL DEFAULT 0,
total_migrated bigint(20) unsigned NOT NULL DEFAULT 0,
status varchar(20) NOT NULL DEFAULT \'pending\',
started_at datetime DEFAULT NULL,
completed_at datetime DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY entity_group (entity_type, group_name)
) DEFAULT CHARACTER SET utf8mb4'
);
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_migration_status`' );
WPDO_Entity_Registry::init();
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::FLAT . '`' );
$wpdb->query( 'TRUNCATE TABLE `wp_itest_wpdo_migration_status`' );
WPDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'attachment' );
}
private function seed_attachment( int $post_id, array $metadata, string $alt = '' ): void {
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => $post_id, 'post_type' => 'attachment' ) );
// Real WP serializes _wp_attachment_metadata via PHP serialize().
$wpdb->insert( self::POSTMETA, array(
'post_id' => $post_id,
'meta_key' => '_wp_attachment_metadata',
'meta_value' => serialize( $metadata ),
) );
if ( '' !== $alt ) {
$wpdb->insert( self::POSTMETA, array(
'post_id' => $post_id,
'meta_key' => '_wp_attachment_image_alt',
'meta_value' => $alt,
) );
}
}
// ── backfill_group_json() ────────────────────────────────────────────────
public function test_unserializes_attachment_metadata_to_json(): void {
$this->seed_attachment( 1, array(
'width' => 800,
'height' => 600,
'file' => '2026/04/test.jpg',
'sizes' => array(
'thumbnail' => array( 'width' => 150, 'height' => 150 ),
),
), 'Stress test alt' );
$result = WPDO_Post_Migration::backfill_group_json( 'attachment' );
$this->assertGreaterThan( 0, $result['migrated'] ?? 0 );
$this->assertSame( 0, $result['errors'] ?? -1 );
global $wpdb;
$row = $wpdb->get_row(
'SELECT _wp_attachment_metadata, _wp_attachment_image_alt FROM `' . self::FLAT . '` WHERE post_id = 1',
ARRAY_A
);
$this->assertNotNull( $row );
// metadata value should now be JSON.
$decoded = json_decode( (string) $row['_wp_attachment_metadata'], true );
$this->assertIsArray( $decoded );
$this->assertSame( 800, $decoded['width'] );
$this->assertSame( 'thumbnail', array_keys( $decoded['sizes'] )[0] );
// Non-json field still passes through.
$this->assertSame( 'Stress test alt', $row['_wp_attachment_image_alt'] );
}
public function test_idempotent_re_run(): void {
$this->seed_attachment( 1, array( 'width' => 100 ) );
WPDO_Post_Migration::backfill_group_json( 'attachment' );
// reset checkpoint so the engine reprocesses the same row.
WPDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'attachment' );
$result2 = WPDO_Post_Migration::backfill_group_json( 'attachment' );
$this->assertSame( 0, $result2['errors'] ?? -1 );
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' );
$this->assertSame( 1, $count, 'No duplicate row after re-run.' );
}
public function test_handles_already_serialized_string_safely(): void {
// Attacker-style: write an object signature into _wp_attachment_metadata
// (this is what safe_unserialize defends against).
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => 99, 'post_type' => 'attachment' ) );
$wpdb->insert( self::POSTMETA, array(
'post_id' => 99,
'meta_key' => '_wp_attachment_metadata',
'meta_value' => 'O:8:"stdClass":0:{}', // Object string — should be NULL'd
) );
$result = WPDO_Post_Migration::backfill_group_json( 'attachment' );
// Engine should NOT throw — safe_unserialize converts object to NULL.
$this->assertSame( 0, $result['errors'] ?? -1 );
}
public function test_handles_empty_postmeta_gracefully(): void {
$result = WPDO_Post_Migration::backfill_group_json( 'attachment' );
$this->assertSame( 0, $result['migrated'] ?? -1 );
$this->assertSame( 0, $result['errors'] ?? -1 );
}
public function test_returns_error_for_unknown_group(): void {
$result = WPDO_Post_Migration::backfill_group_json( 'bogus_group' );
// Engine returns error_result with 'error' key set.
$this->assertNotEmpty( $result['error'] ?? null );
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Post_Migration::benchmark_query() (v2.10.2).
*
* Verifies the benchmark method produces sensible timing comparison
* between wp_postmeta JOIN and flat-table JOIN for the same logical query.
*
* Tests focus on the API contract (input → output shape) since wall-clock
* timing is non-deterministic. Real performance numbers come from running
* `wp wpdo post-benchmark` on dev10 production data.
*/
class PostBenchmarkTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const POSTMETA = 'wp_itest_postmeta';
private const FLAT = 'wp_itest_wpdo_post_wc_product';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Schema_Manager' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/engine/class-tmdo-schema-manager.php';
}
if ( ! class_exists( 'WPDO_Post_Migration' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/migration/class-tmdo-post-migration.php';
}
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTS . '` (
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_type varchar(20) NOT NULL DEFAULT \'post\',
post_status varchar(20) NOT NULL DEFAULT \'publish\',
PRIMARY KEY (ID),
KEY post_type (post_type)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY post_id (post_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
$wpdb->query(
'CREATE TABLE `' . self::FLAT . '` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
_price decimal(18,6) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_post_id (post_id),
KEY idx__price (_price)
) DEFAULT CHARACTER SET utf8mb4'
);
// Seed 50 products with _price = 10..59 in both tables.
for ( $i = 1; $i <= 50; $i++ ) {
$wpdb->insert( self::POSTS, array( 'ID' => $i, 'post_type' => 'product', 'post_status' => 'publish' ) );
$price = (string) ( 10 + $i );
$wpdb->insert( self::POSTMETA, array( 'post_id' => $i, 'meta_key' => '_price', 'meta_value' => $price ) );
$wpdb->insert( self::FLAT, array( 'post_id' => $i, '_price' => $price ) );
}
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
}
// ── benchmark_query() ────────────────────────────────────────────────────
public function test_benchmark_returns_expected_shape(): void {
$result = WPDO_Post_Migration::benchmark_query(
'product',
'_price',
'>=',
'30',
self::FLAT,
5
);
$this->assertArrayHasKey( 'samples', $result );
$this->assertArrayHasKey( 'postmeta_avg_ms', $result );
$this->assertArrayHasKey( 'flat_avg_ms', $result );
$this->assertArrayHasKey( 'speedup', $result );
$this->assertArrayHasKey( 'postmeta_rows', $result );
$this->assertArrayHasKey( 'flat_rows', $result );
$this->assertSame( 5, $result['samples'] );
$this->assertGreaterThan( 0.0, $result['postmeta_avg_ms'] );
$this->assertGreaterThan( 0.0, $result['flat_avg_ms'] );
}
public function test_benchmark_finds_same_rows_via_both_paths(): void {
// _price >= 30 should match products 20..50 (i.e. 31 rows).
$result = WPDO_Post_Migration::benchmark_query(
'product',
'_price',
'>=',
'30',
self::FLAT,
3
);
// Both paths must return the same count — verifies router correctness.
$this->assertSame( $result['postmeta_rows'], $result['flat_rows'] );
$this->assertGreaterThan( 0, $result['postmeta_rows'] );
}
public function test_benchmark_speedup_is_positive_number(): void {
$result = WPDO_Post_Migration::benchmark_query(
'product',
'_price',
'=',
'25',
self::FLAT,
3
);
$this->assertIsFloat( $result['speedup'] );
$this->assertGreaterThan( 0.0, $result['speedup'] );
}
public function test_benchmark_rejects_zero_samples(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Migration::benchmark_query( 'product', '_price', '=', '25', self::FLAT, 0 );
}
public function test_benchmark_rejects_invalid_compare(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Migration::benchmark_query( 'product', '_price', 'BOGUS', '25', self::FLAT, 3 );
}
public function test_benchmark_throws_when_flat_table_missing(): void {
$this->expectException( RuntimeException::class );
WPDO_Post_Migration::benchmark_query(
'product',
'_price',
'=',
'25',
'wp_itest_nonexistent_flat',
3
);
}
}
@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: Post Entity end-to-end lifecycle (v2.9.6).
*
* Stitches every phase shipped in v2.9.0 → v2.9.5 into a single regression
* net. If any future change breaks the contract between two phases (e.g.
* Postmeta_Cleaner output format vs Post_Migration::backfill_group input),
* this test fails before it ships.
*
* Phase coverage:
* v2.9.0 Postmeta_Cleaner::count_garbage / delete_garbage
* v2.9.1 Post_Fields::register_entity_fields → groups visible in Registry
* v2.9.2 Sync_Bridge guard (verified separately in SyncBridgeEntityGuardTest)
* v2.9.3 Post_Migration::diagnose / backfill_group / cleanup
* v2.9.4 Post_Stress_Tester::create / count / cleanup
* v2.9.5 Post_Migration::copy_legacy_hot_table / verify_legacy_cutover
*/
class PostEntityLifecycleTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const POSTMETA = 'wp_itest_postmeta';
private const FLAT = 'wp_itest_wpdo_post_wc_product';
public static function setUpBeforeClass(): void {
global $wpdb;
// Load full chain.
$plugin_dir = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/integrations/class-tmdo-post-fields.php',
'includes/migration/class-tmdo-post-migration.php',
'includes/class-tmdo-postmeta-cleaner.php',
'includes/class-tmdo-post-stress-tester.php',
) as $rel ) {
$file = $plugin_dir . $rel;
$class_name = self::class_for( $rel );
if ( $class_name && ! class_exists( $class_name ) && ! interface_exists( $class_name ) ) {
require_once $file;
}
}
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTS . '` (
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_title text NOT NULL,
post_type varchar(20) NOT NULL DEFAULT \'post\',
post_status varchar(20) NOT NULL DEFAULT \'publish\',
post_date datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
post_date_gmt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
post_modified datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
post_modified_gmt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
post_author bigint(20) unsigned NOT NULL DEFAULT 0,
post_content longtext NOT NULL,
post_excerpt text NOT NULL,
comment_status varchar(20) NOT NULL DEFAULT \'open\',
ping_status varchar(20) NOT NULL DEFAULT \'open\',
post_password varchar(255) NOT NULL DEFAULT \'\',
post_name varchar(200) NOT NULL DEFAULT \'\',
to_ping text NOT NULL,
pinged text NOT NULL,
post_content_filtered longtext NOT NULL,
post_parent bigint(20) unsigned NOT NULL DEFAULT 0,
guid varchar(255) NOT NULL DEFAULT \'\',
menu_order int(11) NOT NULL DEFAULT 0,
post_mime_type varchar(100) NOT NULL DEFAULT \'\',
comment_count bigint(20) NOT NULL DEFAULT 0,
PRIMARY KEY (ID),
KEY post_type (post_type),
KEY post_title (post_title(64))
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY post_id (post_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4'
);
// wc_product flat target with all 19 columns.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
$wpdb->query(
'CREATE TABLE `' . self::FLAT . '` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
_price decimal(18,6) DEFAULT NULL,
_regular_price decimal(18,6) DEFAULT NULL,
_sale_price decimal(18,6) DEFAULT NULL,
_stock bigint(20) DEFAULT NULL,
_stock_status varchar(100) DEFAULT NULL,
_sku varchar(255) DEFAULT NULL,
_manage_stock varchar(100) DEFAULT NULL,
_backorders varchar(100) DEFAULT NULL,
_sold_individually varchar(100) DEFAULT NULL,
_virtual varchar(100) DEFAULT NULL,
_downloadable varchar(100) DEFAULT NULL,
_tax_class varchar(255) DEFAULT NULL,
_tax_status varchar(100) DEFAULT NULL,
_download_limit bigint(20) DEFAULT NULL,
_download_expiry bigint(20) DEFAULT NULL,
_product_version varchar(255) DEFAULT NULL,
_wc_average_rating decimal(18,6) DEFAULT NULL,
_wc_review_count bigint(20) DEFAULT NULL,
total_sales bigint(20) DEFAULT NULL,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_post_id (post_id)
) DEFAULT CHARACTER SET utf8mb4'
);
// Register post adapter + groups.
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
// Reset Mode_Manager + Entity_Registry to avoid leaking state.
$ref = new ReflectionClass( WPDO_Mode_Manager::class );
$cache = $ref->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, null );
WPDO_Entity_Registry::init();
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::FLAT . '`' );
}
private static function class_for( string $rel ): ?string {
$map = array(
'interface-entity-adapter.php' => 'WPDO_Entity_Adapter_Interface',
'class-tmdo-entity-registry.php' => 'WPDO_Entity_Registry',
'class-tmdo-mode-manager.php' => 'WPDO_Mode_Manager',
'class-tmdo-schema-manager.php' => 'WPDO_Schema_Manager',
'class-tmdo-adapter-post.php' => 'WPDO_Adapter_Post',
'class-tmdo-post-fields.php' => 'WPDO_Post_Fields',
'class-tmdo-post-migration.php' => 'WPDO_Post_Migration',
'class-tmdo-postmeta-cleaner.php' => 'WPDO_Postmeta_Cleaner',
'class-tmdo-post-stress-tester.php' => 'WPDO_Post_Stress_Tester',
);
foreach ( $map as $needle => $cls ) {
if ( str_contains( $rel, $needle ) ) {
return $cls;
}
}
return null;
}
// ── End-to-end lifecycle ──────────────────────────────────────────────────
/**
* Full lifecycle: stress create → cleanup garbage → backfill → diagnose
* → stress cleanup → final ratio assertion.
*
* This test is the contract net for v2.9.x phases working together.
*/
public function test_full_post_entity_lifecycle(): void {
global $wpdb;
// Phase 1 (v2.9.4): seed 10 product posts via stress tester.
$create_result = WPDO_Post_Stress_Tester::create( 'product', 10 );
$this->assertSame( 10, $create_result['created'] );
$this->assertSame( 10, WPDO_Post_Stress_Tester::count_test_posts() );
// Add some garbage to validate v2.9.0 cleanup phase.
for ( $i = 0; $i < 5; $i++ ) {
$wpdb->insert( self::POSTMETA, array(
'post_id' => 1,
'meta_key' => '_transient_test_' . $i,
'meta_value' => 'x',
) );
$wpdb->insert( self::POSTMETA, array(
'post_id' => 1,
'meta_key' => '_wp_old_date',
'meta_value' => '2024-01-01',
) );
}
// Phase 2 (v2.9.0): cleanup garbage.
$garbage_before = WPDO_Postmeta_Cleaner::count_garbage( 'all' );
$this->assertSame( 5, $garbage_before['transients'] );
$this->assertSame( 5, $garbage_before['wp_old_date'] );
$deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'all' );
$this->assertSame( 10, $deleted['total'] );
$garbage_after = WPDO_Postmeta_Cleaner::count_garbage( 'all' );
$this->assertSame( 0, $garbage_after['total'], 'After delete, all garbage gone.' );
// Phase 3 (v2.9.3): diagnose post entity state.
$diag1 = WPDO_Post_Migration::diagnose();
$this->assertSame( 10, $diag1['posts'] );
$this->assertSame( 'disabled', $diag1['mode'] );
$this->assertGreaterThan( 0, $diag1['groups']['wc_product']['eav_rows'], 'wc_product seeded keys present.' );
$this->assertSame( 0, $diag1['groups']['wc_product']['flat_rows'], 'flat empty before backfill.' );
// Phase 4 (v2.9.3): backfill wc_product from postmeta to flat.
$backfill = WPDO_Post_Migration::backfill_group( 'wc_product' );
$this->assertSame( 10, $backfill['migrated'], '10 products backfilled to flat.' );
// Phase 5: re-diagnose; flat_rows must equal post count for wc_product.
$diag2 = WPDO_Post_Migration::diagnose();
$this->assertSame( 10, $diag2['groups']['wc_product']['flat_rows'] );
// Phase 6 (v2.9.4): stress cleanup removes everything.
$cleanup = WPDO_Post_Stress_Tester::cleanup();
$this->assertSame( 10, $cleanup['deleted_posts'] );
$this->assertSame( 0, WPDO_Post_Stress_Tester::count_test_posts() );
// Final state: empty everywhere.
$diag3 = WPDO_Post_Migration::diagnose();
$this->assertSame( 0, $diag3['posts'] );
$this->assertSame( 0, $diag3['groups']['wc_product']['eav_rows'] );
// Note: flat rows survive stress cleanup (ON DELETE CASCADE not configured
// in test fixture); production v2.9.4 cleanup() also sweeps flat tables.
$this->assertGreaterThanOrEqual( 0, $diag3['groups']['wc_product']['flat_rows'] );
}
/**
* Verifies that v2.9.0 cleanup + v2.9.3 backfill have zero overlap:
* cleanup keys (_transient_*, _wp_old_date, stale _edit_lock) must not
* collide with any v2.9.1 entity group's managed keys.
*/
public function test_cleanup_keys_never_overlap_managed_group_keys(): void {
$managed = WPDO_Post_Migration::get_managed_keys();
foreach ( $managed as $key ) {
$this->assertStringStartsNotWith( '_transient_', $key );
$this->assertStringStartsNotWith( '_transient_timeout_', $key );
$this->assertNotSame( '_wp_old_date', $key );
$this->assertNotSame( '_edit_lock', $key );
}
}
}
+239
View File
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Post_Migration::copy_legacy_hot_table() (v2.9.5).
*
* Verifies non-destructive cutover from legacy `wpdo_hot_<post_type>` table
* to the new `wp_wpdo_post_<group>` flat table:
*
* - copies common columns by name intersection
* - skips auto_increment id + updated_at columns (let flat manage them)
* - idempotent (re-run doesn't duplicate; ON DUPLICATE KEY UPDATE)
* - leaves the legacy table untouched (safety net for v3.0.0 DROP)
* - verify_legacy_cutover() reports row count + sample mismatches
*/
class PostLegacyCutoverTest extends TestCase {
private const HOT_TABLE = 'wp_itest_wpdo_hot_hp_listing';
private const FLAT_TABLE = 'wp_itest_wpdo_post_hp_listing_core';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Post_Migration' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/migration/class-tmdo-post-migration.php';
}
// Legacy hot table — narrower schema, hp_booking_enabled is hot-only.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::HOT_TABLE . '`' );
$wpdb->query(
'CREATE TABLE `' . self::HOT_TABLE . '` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
hp_price decimal(10,2) NOT NULL DEFAULT 0.00,
hp_featured tinyint(1) NOT NULL DEFAULT 0,
hp_verified tinyint(1) NOT NULL DEFAULT 0,
hp_expired_time bigint(20) NOT NULL DEFAULT 0,
hp_featured_time bigint(20) NOT NULL DEFAULT 0,
updated_at datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
hp_booking_enabled tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY post_id (post_id)
) DEFAULT CHARACTER SET utf8mb4'
);
// Flat target — wider schema, includes hp_status/hp_vendor not in hot.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT_TABLE . '`' );
$wpdb->query(
'CREATE TABLE `' . self::FLAT_TABLE . '` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
hp_price decimal(18,6) DEFAULT NULL,
hp_status varchar(100) DEFAULT NULL,
hp_featured bigint(20) DEFAULT NULL,
hp_verified bigint(20) DEFAULT NULL,
hp_vendor bigint(20) DEFAULT NULL,
hp_expired_time bigint(20) DEFAULT NULL,
hp_featured_time bigint(20) DEFAULT NULL,
hp_view_count bigint(20) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_post_id (post_id)
) DEFAULT CHARACTER SET utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::HOT_TABLE . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT_TABLE . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::HOT_TABLE . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::FLAT_TABLE . '`' );
}
private function seed_hot( int $post_id, array $cols ): void {
global $wpdb;
$wpdb->insert( self::HOT_TABLE, array_merge( array( 'post_id' => $post_id ), $cols ) );
}
// ── copy_legacy_hot_table() ──────────────────────────────────────────────
public function test_copy_legacy_hot_table_copies_all_rows(): void {
// Seed 5 rows.
for ( $i = 1; $i <= 5; $i++ ) {
$this->seed_hot( 100 + $i, array(
'hp_price' => 50.00 + $i,
'hp_featured' => $i % 2,
'hp_verified' => 1,
'hp_expired_time' => 9999999 + $i,
'hp_featured_time' => 0,
) );
}
$result = WPDO_Post_Migration::copy_legacy_hot_table(
'hp_listing',
self::HOT_TABLE,
self::FLAT_TABLE
);
$this->assertSame( 5, $result['copied'] );
global $wpdb;
$flat_count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '`' );
$this->assertSame( 5, $flat_count );
// Verify a row's data round-trips.
$row = $wpdb->get_row( 'SELECT hp_price, hp_featured, hp_verified, hp_expired_time FROM `' . self::FLAT_TABLE . '` WHERE post_id = 103', ARRAY_A );
$this->assertSame( '53.000000', $row['hp_price'] );
$this->assertSame( '1', $row['hp_featured'] ); // 3 % 2 = 1
$this->assertSame( '1', $row['hp_verified'] );
$this->assertSame( '10000002', $row['hp_expired_time'] );
}
public function test_copy_skips_id_and_updated_at_columns(): void {
$this->seed_hot( 200, array(
'hp_price' => 99.99,
'hp_featured' => 0,
'hp_verified' => 1,
'hp_expired_time' => 0,
'hp_featured_time' => 0,
) );
WPDO_Post_Migration::copy_legacy_hot_table(
'hp_listing',
self::HOT_TABLE,
self::FLAT_TABLE
);
global $wpdb;
// Flat row's id should be auto-assigned (not the hot row's id).
// Flat row's updated_at should be CURRENT_TIMESTAMP (not 0000-00-00).
$row = $wpdb->get_row( 'SELECT id, updated_at FROM `' . self::FLAT_TABLE . '` WHERE post_id = 200', ARRAY_A );
$this->assertNotEmpty( $row['updated_at'] );
$this->assertNotEquals( '0000-00-00 00:00:00', $row['updated_at'] );
}
public function test_copy_legacy_hot_table_is_idempotent(): void {
$this->seed_hot( 300, array(
'hp_price' => 10.00,
'hp_featured' => 0,
'hp_verified' => 1,
'hp_expired_time' => 0,
'hp_featured_time' => 0,
) );
WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE );
WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE );
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '`' );
$this->assertSame( 1, $count, 'Re-running copy is idempotent — UPSERT, no duplicate.' );
}
public function test_copy_does_not_modify_legacy_hot_table(): void {
$this->seed_hot( 400, array(
'hp_price' => 25.00,
'hp_featured' => 0,
'hp_verified' => 1,
'hp_expired_time' => 0,
'hp_featured_time' => 0,
) );
global $wpdb;
$before = $wpdb->get_results( 'SELECT * FROM `' . self::HOT_TABLE . '` ORDER BY id', ARRAY_A );
WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE );
$after = $wpdb->get_results( 'SELECT * FROM `' . self::HOT_TABLE . '` ORDER BY id', ARRAY_A );
$this->assertEquals( $before, $after, 'Legacy hot table must remain untouched (safety net for v3.0.0).' );
}
public function test_copy_returns_zero_when_hot_table_empty(): void {
$result = WPDO_Post_Migration::copy_legacy_hot_table(
'hp_listing',
self::HOT_TABLE,
self::FLAT_TABLE
);
$this->assertSame( 0, $result['copied'] );
}
public function test_copy_throws_when_hot_table_missing(): void {
$this->expectException( RuntimeException::class );
WPDO_Post_Migration::copy_legacy_hot_table(
'hp_listing',
'wp_itest_nonexistent_hot',
self::FLAT_TABLE
);
}
// ── verify_legacy_cutover() ──────────────────────────────────────────────
public function test_verify_reports_match_when_counts_equal(): void {
for ( $i = 1; $i <= 3; $i++ ) {
$this->seed_hot( 500 + $i, array(
'hp_price' => $i * 10,
'hp_featured' => 0,
'hp_verified' => 1,
'hp_expired_time' => 0,
'hp_featured_time' => 0,
) );
}
WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE );
$verify = WPDO_Post_Migration::verify_legacy_cutover( self::HOT_TABLE, self::FLAT_TABLE );
$this->assertSame( 3, $verify['hot_rows'] );
$this->assertSame( 3, $verify['flat_rows'] );
$this->assertSame( 0, $verify['mismatched_rows'] );
$this->assertTrue( $verify['ok'] );
}
public function test_verify_reports_mismatch_when_flat_lags_hot(): void {
// Seed hot with 3 rows.
for ( $i = 1; $i <= 3; $i++ ) {
$this->seed_hot( 600 + $i, array(
'hp_price' => $i * 10,
'hp_featured' => 0,
'hp_verified' => 1,
'hp_expired_time' => 0,
'hp_featured_time' => 0,
) );
}
// Don't run copy — flat stays empty.
$verify = WPDO_Post_Migration::verify_legacy_cutover( self::HOT_TABLE, self::FLAT_TABLE );
$this->assertSame( 3, $verify['hot_rows'] );
$this->assertSame( 0, $verify['flat_rows'] );
$this->assertFalse( $verify['ok'] );
}
}
+240
View File
@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Post_Migration core methods (v2.9.3).
*
* Verifies diagnose(), backfill_group(), cutover() against real MariaDB,
* mirroring the user-side WPDO_Migration_Orchestrator's contract but using
* a new independent class so the user orchestrator's 1105 lines stay frozen.
*
* Requires real MariaDB (WPDO_TEST_DB_PASS env var must be set).
*/
class PostMigrationTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const POSTMETA = 'wp_itest_postmeta';
private const FLAT = 'wp_itest_wpdo_post_wc_product';
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
// Load post entity chain.
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/interface-entity-adapter.php';
}
if ( ! class_exists( 'WPDO_Entity_Registry' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/engine/class-tmdo-entity-registry.php';
}
if ( ! class_exists( 'WPDO_Mode_Manager' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/engine/class-tmdo-mode-manager.php';
}
if ( ! class_exists( 'WPDO_Schema_Manager' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/engine/class-tmdo-schema-manager.php';
}
if ( ! class_exists( 'WPDO_Adapter_Post' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/class-tmdo-adapter-post.php';
}
if ( ! class_exists( 'WPDO_Post_Fields' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-post-fields.php';
}
if ( ! class_exists( 'WPDO_Post_Migration' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/migration/class-tmdo-post-migration.php';
}
// wp_posts (minimal — only ID + post_type).
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTS . '` (
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_type varchar(20) NOT NULL DEFAULT \'post\',
PRIMARY KEY (ID),
KEY post_type (post_type)
) DEFAULT CHARACTER SET utf8mb4'
);
// wp_postmeta — same schema as production WP.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY post_id (post_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4'
);
// Flat target for wc_product group (subset of full schema for test).
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
$wpdb->query(
'CREATE TABLE `' . self::FLAT . '` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
_price decimal(18,6) DEFAULT NULL,
_regular_price decimal(18,6) DEFAULT NULL,
_stock bigint(20) DEFAULT NULL,
_stock_status varchar(100) DEFAULT NULL,
_sku varchar(255) DEFAULT NULL,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_post_id (post_id)
) DEFAULT CHARACTER SET utf8mb4'
);
// Reset Entity Registry + register post adapter + post fields.
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
// Reset Mode_Manager + Entity_Registry to avoid polluting later tests.
$ref = new ReflectionClass( WPDO_Mode_Manager::class );
$cache = $ref->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, null );
WPDO_Entity_Registry::init();
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::FLAT . '`' );
}
private function seed_post( int $id, string $post_type ): void {
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => $id, 'post_type' => $post_type ) );
}
private function seed_meta( int $post_id, string $key, string $value ): void {
global $wpdb;
$wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => $key, 'meta_value' => $value ) );
}
// ── diagnose() ────────────────────────────────────────────────────────────
public function test_diagnose_reports_posts_postmeta_ratio(): void {
// 5 posts, 12 postmeta rows → ratio 2.4
for ( $i = 1; $i <= 5; $i++ ) {
$this->seed_post( $i, 'post' );
}
for ( $i = 0; $i < 12; $i++ ) {
$this->seed_meta( ( $i % 5 ) + 1, 'random_key', 'v' );
}
$result = WPDO_Post_Migration::diagnose();
$this->assertSame( 5, $result['posts'] );
$this->assertSame( 12, $result['postmeta'] );
$this->assertSame( 2.4, $result['ratio'] );
}
public function test_diagnose_reports_eav_rows_per_managed_group(): void {
$this->seed_post( 1, 'product' );
$this->seed_meta( 1, '_price', '99.99' );
$this->seed_meta( 1, '_stock', '5' );
$this->seed_meta( 1, 'unrelated_key', 'x' ); // not in any group
$result = WPDO_Post_Migration::diagnose();
$this->assertArrayHasKey( 'groups', $result );
$this->assertArrayHasKey( 'wc_product', $result['groups'] );
$this->assertSame(
2,
$result['groups']['wc_product']['eav_rows'],
'wc_product group has 2 EAV rows: _price + _stock (unrelated_key excluded).'
);
}
public function test_diagnose_reports_zero_eav_for_unused_group(): void {
$this->seed_post( 1, 'post' );
$this->seed_meta( 1, 'random_key', 'v' );
$result = WPDO_Post_Migration::diagnose();
// nav_menu_item group has no postmeta seeded.
$this->assertSame( 0, $result['groups']['nav_menu_item']['eav_rows'] );
}
public function test_diagnose_reports_post_mode(): void {
$result = WPDO_Post_Migration::diagnose();
$this->assertArrayHasKey( 'mode', $result );
// Default post mode is 'disabled' per Mode_Manager defaults().
$this->assertSame( 'disabled', $result['mode'] );
}
// ── backfill_group() — bulk SQL pivot ─────────────────────────────────────
public function test_backfill_group_pivots_wc_product_keys(): void {
$this->seed_post( 1, 'product' );
$this->seed_meta( 1, '_price', '99.99' );
$this->seed_meta( 1, '_regular_price', '120.00' );
$this->seed_meta( 1, '_stock', '5' );
$this->seed_meta( 1, '_stock_status', 'instock' );
$this->seed_meta( 1, '_sku', 'SKU-001' );
$this->seed_post( 2, 'product' );
$this->seed_meta( 2, '_price', '49.50' );
$this->seed_meta( 2, '_stock_status', 'outofstock' );
$result = WPDO_Post_Migration::backfill_group( 'wc_product' );
$this->assertSame( 2, $result['migrated'], 'Two posts produce two flat rows.' );
global $wpdb;
$row1 = $wpdb->get_row( 'SELECT _price, _stock, _sku FROM `' . self::FLAT . '` WHERE post_id = 1', ARRAY_A );
$this->assertSame( '99.990000', $row1['_price'], 'Price decimal stored with full precision.' );
$this->assertSame( '5', $row1['_stock'] );
$this->assertSame( 'SKU-001', $row1['_sku'] );
$row2 = $wpdb->get_row( 'SELECT _price, _stock_status, _sku FROM `' . self::FLAT . '` WHERE post_id = 2', ARRAY_A );
$this->assertSame( '49.500000', $row2['_price'] );
$this->assertSame( 'outofstock', $row2['_stock_status'] );
$this->assertNull( $row2['_sku'], 'Unset key remains NULL in flat row.' );
}
public function test_backfill_group_is_idempotent(): void {
$this->seed_post( 1, 'product' );
$this->seed_meta( 1, '_price', '50.00' );
WPDO_Post_Migration::backfill_group( 'wc_product' );
$result_second = WPDO_Post_Migration::backfill_group( 'wc_product' );
$this->assertSame( 1, $result_second['migrated'], 'Re-running backfill is idempotent (UPSERT).' );
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' );
$this->assertSame( 1, $count, 'No duplicate rows after re-run.' );
}
public function test_backfill_group_skips_posts_of_wrong_type(): void {
// _price on a non-product post should NOT migrate to wc_product flat.
$this->seed_post( 99, 'post' );
$this->seed_meta( 99, '_price', '100.00' );
$result = WPDO_Post_Migration::backfill_group( 'wc_product' );
$this->assertSame( 0, $result['migrated'], 'Posts of wrong type are excluded by post_type filter.' );
}
public function test_backfill_group_rejects_invalid_group(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Migration::backfill_group( 'bogus_group' );
}
}
+245
View File
@@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Post_Query_Router (v2.10.1).
*
* Verifies meta_query rewriting when post mode is reads_from_flat (i.e.
* shadow_read or aeav_only). The router strips clauses targeting registered
* Entity Registry keys, JOINs the corresponding flat table, and appends
* WHERE conditions in SQL.
*
* 🔒 Frozen contract:
* - mode=disabled → router pass-through (zero modification)
* - mode=dual_write → router pass-through (wp_postmeta still source-of-truth)
* - mode=shadow_read → router rewrites (flat is read-replica candidate)
* - mode=aeav_only → router rewrites (flat is source-of-truth)
*/
class PostQueryRouterTest extends TestCase {
public static function setUpBeforeClass(): void {
$plugin_dir = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/integrations/class-tmdo-post-fields.php',
'includes/query/class-tmdo-post-query-router.php',
) as $rel ) {
$file = $plugin_dir . $rel;
if ( file_exists( $file ) ) {
require_once $file;
}
}
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
}
public static function tearDownAfterClass(): void {
$ref = new ReflectionClass( WPDO_Mode_Manager::class );
$cache = $ref->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, null );
WPDO_Entity_Registry::init();
}
private function set_post_mode( string $mode ): void {
$ref = new ReflectionClass( WPDO_Mode_Manager::class );
$cache = $ref->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, array(
'post' => $mode,
'user' => 'aeav_only',
'term' => 'dual_write',
'comment' => 'dual_write',
) );
}
private function make_query( array $vars ): WP_Query {
$q = new WP_Query();
foreach ( $vars as $k => $v ) {
$q->set( $k, $v );
}
return $q;
}
// ── pre_get_posts gate (mode-aware) ───────────────────────────────────────
public function test_pass_through_when_mode_disabled(): void {
$this->set_post_mode( 'disabled' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
),
) );
$original = $query->get( 'meta_query' );
$router->pre_get_posts( $query );
$this->assertSame(
$original,
$query->get( 'meta_query' ),
'mode=disabled: meta_query must be untouched.'
);
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
}
public function test_pass_through_when_mode_dual_write(): void {
$this->set_post_mode( 'dual_write' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
),
) );
$original = $query->get( 'meta_query' );
$router->pre_get_posts( $query );
$this->assertSame(
$original,
$query->get( 'meta_query' ),
'mode=dual_write: wp_postmeta is still source-of-truth, no rewrite.'
);
}
// ── pre_get_posts rewrite (mode=aeav_only) ───────────────────────────────
public function test_rewrites_meta_query_when_mode_aeav_only(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
),
) );
$router->pre_get_posts( $query );
// Original meta_query stripped of registered keys.
$remaining = $query->get( 'meta_query' );
$this->assertEmpty(
$remaining,
'aeav_only: registered keys removed from meta_query.'
);
// Routed clauses captured under wpdo_post_clauses query var.
$routed = $query->get( 'wpdo_post_clauses' );
$this->assertIsArray( $routed );
$this->assertNotEmpty( $routed );
}
public function test_keeps_unmanaged_keys_in_meta_query(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
),
) );
$router->pre_get_posts( $query );
$remaining = $query->get( 'meta_query' );
$this->assertCount( 1, $remaining );
// Original index preserved (k=1 since k=0 was the routed _price clause).
$first_clause = reset( $remaining );
$this->assertSame(
'unmanaged_attr',
$first_clause['key'],
'Unmanaged key remains in meta_query (Hook Bus pass-through).'
);
}
public function test_skips_admin_requests(): void {
$this->set_post_mode( 'aeav_only' );
$prev_admin = $GLOBALS['_wp_is_admin'] ?? false;
$GLOBALS['_wp_is_admin'] = true;
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
),
) );
$original = $query->get( 'meta_query' );
$router->pre_get_posts( $query );
$this->assertSame(
$original,
$query->get( 'meta_query' ),
'is_admin requests should not be rewritten.'
);
$GLOBALS['_wp_is_admin'] = $prev_admin;
}
// ── posts_join / posts_where (SQL emission) ──────────────────────────────
public function test_posts_join_emits_left_join_for_each_routed_post_type(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50' ),
),
'wpdo_post_clauses' => array(),
) );
$router->pre_get_posts( $query );
$join = $router->posts_join( '', $query );
$this->assertStringContainsString( 'LEFT JOIN', $join );
$this->assertStringContainsString( 'wpdo_post_wc_product', $join );
}
public function test_posts_where_appends_condition_for_routed_clause(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '99', 'compare' => '=' ),
),
) );
$router->pre_get_posts( $query );
$where = $router->posts_where( '', $query );
$this->assertStringContainsString( '`_price`', $where );
$this->assertStringContainsString( "'99'", $where );
}
public function test_pass_through_when_no_meta_query(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array( 'post_type' => 'product' ) );
$router->pre_get_posts( $query );
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
}
}
@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Post_Shadow_Verifier (v2.10.3).
*
* Verifies sample-and-compare logic between flat tables and wp_postmeta.
* Result tuple: {sampled, matched, diffs, missing_flat, missing_postmeta}.
*
* Logger integration is exercised via WPDO_Shadow_Diff_Logger; this test
* focuses on the verifier's sampling + counting contract.
*/
class PostShadowVerifierTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const POSTMETA = 'wp_itest_postmeta';
private const FLAT = 'wp_itest_wpdo_post_wc_product';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/interface-entity-adapter.php';
}
foreach ( array(
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/integrations/class-tmdo-post-fields.php',
'includes/class-tmdo-post-shadow-verifier.php',
) as $rel ) {
$file = WPDO_PLUGIN_DIR . $rel;
if ( file_exists( $file ) ) {
require_once $file;
}
}
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTS . '` (
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_type varchar(20) NOT NULL DEFAULT \'post\',
post_status varchar(20) NOT NULL DEFAULT \'publish\',
PRIMARY KEY (ID),
KEY post_type (post_type)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY post_id (post_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
$wpdb->query(
'CREATE TABLE `' . self::FLAT . '` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
_price decimal(18,6) DEFAULT NULL,
_stock_status varchar(100) DEFAULT NULL,
_sku longtext DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_post_id (post_id)
) DEFAULT CHARACTER SET utf8mb4'
);
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' );
WPDO_Entity_Registry::init();
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::FLAT . '`' );
}
private function seed_consistent( int $post_id, string $price, string $stock ): void {
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => $post_id, 'post_type' => 'product', 'post_status' => 'publish' ) );
$wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => '_price', 'meta_value' => $price ) );
$wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => '_stock_status', 'meta_value' => $stock ) );
$wpdb->insert( self::FLAT, array( 'post_id' => $post_id, '_price' => $price, '_stock_status' => $stock ) );
}
private function seed_diverged( int $post_id, string $pm_price, string $flat_price ): void {
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => $post_id, 'post_type' => 'product', 'post_status' => 'publish' ) );
$wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => '_price', 'meta_value' => $pm_price ) );
$wpdb->insert( self::FLAT, array( 'post_id' => $post_id, '_price' => $flat_price ) );
}
// ── sample_compare() ─────────────────────────────────────────────────────
public function test_returns_expected_shape(): void {
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_price', '_stock_status' ),
5
);
$this->assertArrayHasKey( 'sampled', $result );
$this->assertArrayHasKey( 'matched', $result );
$this->assertArrayHasKey( 'diffs', $result );
$this->assertArrayHasKey( 'missing_flat', $result );
$this->assertArrayHasKey( 'missing_postmeta', $result );
}
public function test_all_match_when_data_is_consistent(): void {
for ( $i = 1; $i <= 5; $i++ ) {
$this->seed_consistent( $i, '50.00', 'instock' );
}
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_price' ),
5
);
$this->assertSame( 5, $result['sampled'] );
$this->assertSame( 5, $result['matched'] );
$this->assertSame( 0, $result['diffs'] );
$this->assertSame( 0, $result['missing_flat'] );
}
public function test_detects_divergence_between_flat_and_postmeta(): void {
// Two diverged: pm has 50, flat has 60.
$this->seed_diverged( 1, '50', '60' );
$this->seed_diverged( 2, '99', '88' );
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_price' ),
5
);
$this->assertSame( 2, $result['sampled'] );
$this->assertSame( 0, $result['matched'] );
$this->assertSame( 2, $result['diffs'] );
}
public function test_counts_missing_flat_when_flat_row_absent(): void {
// Post + postmeta exist, but no flat row.
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => 100, 'post_type' => 'product', 'post_status' => 'publish' ) );
$wpdb->insert( self::POSTMETA, array( 'post_id' => 100, 'meta_key' => '_price', 'meta_value' => '99' ) );
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_price' ),
5
);
$this->assertSame( 1, $result['sampled'] );
$this->assertSame( 1, $result['missing_flat'] );
}
public function test_counts_missing_postmeta_when_pm_absent(): void {
// Post + flat exist, but no postmeta.
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => 200, 'post_type' => 'product', 'post_status' => 'publish' ) );
$wpdb->insert( self::FLAT, array( 'post_id' => 200, '_price' => '50' ) );
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_price' ),
5
);
$this->assertSame( 1, $result['sampled'] );
$this->assertSame( 1, $result['missing_postmeta'] );
}
public function test_returns_zero_when_no_posts_of_type(): void {
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_price' ),
5
);
$this->assertSame( 0, $result['sampled'] );
$this->assertSame( 0, $result['matched'] );
}
public function test_caps_sample_at_available_post_count(): void {
// 3 posts, ask for 10 samples → only 3 sampled.
$this->seed_consistent( 1, '10', 'instock' );
$this->seed_consistent( 2, '20', 'instock' );
$this->seed_consistent( 3, '30', 'instock' );
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_price' ),
10
);
$this->assertSame( 3, $result['sampled'] );
$this->assertSame( 3, $result['matched'] );
}
public function test_rejects_zero_sample_size(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Shadow_Verifier::sample_compare( 'product', 'wc_product', self::FLAT, array( '_price' ), 0 );
}
public function test_rejects_empty_keys(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Shadow_Verifier::sample_compare( 'product', 'wc_product', self::FLAT, array(), 5 );
}
// ── v2.10.5: serialize vs JSON loose equality ────────────────────────────
public function test_treats_serialized_array_equal_to_json_array(): void {
// pm side has serialized array; flat side has JSON for the same data.
// post_id=10, key=_stock_status (we reuse this column to inject test values).
// Use a dedicated key for clarity by re-purposing the keys array.
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => 10, 'post_type' => 'product', 'post_status' => 'publish' ) );
$wpdb->insert( self::POSTMETA, array(
'post_id' => 10,
'meta_key' => '_sku',
'meta_value' => serialize( array( 'a', 'b', 'c' ) ),
) );
$wpdb->insert( self::FLAT, array(
'post_id' => 10,
'_sku' => wp_json_encode( array( 'a', 'b', 'c' ) ),
) );
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_sku' ),
5
);
$this->assertSame(
1,
$result['matched'],
'serialized array vs JSON-encoded same array should match.'
);
$this->assertSame( 0, $result['diffs'], 'No false-positive diff.' );
}
public function test_treats_serialized_assoc_equal_to_json_assoc(): void {
global $wpdb;
$assoc = array( 'width' => 100, 'height' => 200 );
$wpdb->insert( self::POSTS, array( 'ID' => 11, 'post_type' => 'product', 'post_status' => 'publish' ) );
$wpdb->insert( self::POSTMETA, array(
'post_id' => 11,
'meta_key' => '_sku',
'meta_value' => serialize( $assoc ),
) );
$wpdb->insert( self::FLAT, array(
'post_id' => 11,
'_sku' => wp_json_encode( $assoc ),
) );
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_sku' ),
5
);
$this->assertSame( 1, $result['matched'] );
$this->assertSame( 0, $result['diffs'] );
}
public function test_genuine_diff_still_detected_after_loose_equal_widening(): void {
// Real divergence — must still be flagged even with loosened compare.
global $wpdb;
$wpdb->insert( self::POSTS, array( 'ID' => 12, 'post_type' => 'product', 'post_status' => 'publish' ) );
$wpdb->insert( self::POSTMETA, array(
'post_id' => 12,
'meta_key' => '_sku',
'meta_value' => serialize( array( 1, 2, 3 ) ),
) );
$wpdb->insert( self::FLAT, array(
'post_id' => 12,
'_sku' => wp_json_encode( array( 9, 9, 9 ) ),
) );
$result = WPDO_Post_Shadow_Verifier::sample_compare(
'product',
'wc_product',
self::FLAT,
array( '_sku' ),
5
);
$this->assertSame( 0, $result['matched'] );
$this->assertSame( 1, $result['diffs'] );
}
}
+476
View File
@@ -0,0 +1,476 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Post_Stress_Tester (v2.9.4).
*
* Verifies the post-side stress tester contract:
* - create() bulk-inserts N posts with 19 wc_product meta keys
* - count_test_posts() returns the correct count
* - cleanup() removes ALL test posts + their postmeta + flat rows
* - test posts use post_title prefix WPDO_STRESS_TEST_ for identification
*
* Mirrors the user-side WPDO_User_Stress_Tester contract but with a much
* narrower API surface — full polling/cron/benchmark UI deferred (v2.9.4
* scope is bulk fixture generation for v2.9.5 cutover validation).
*/
class PostStressTesterTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const POSTMETA = 'wp_itest_postmeta';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Post_Stress_Tester' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-post-stress-tester.php';
}
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTS . '` (
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_title text NOT NULL,
post_type varchar(20) NOT NULL DEFAULT \'post\',
post_status varchar(20) NOT NULL DEFAULT \'publish\',
post_date datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
post_date_gmt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
post_modified datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
post_modified_gmt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
post_author bigint(20) unsigned NOT NULL DEFAULT 0,
post_content longtext NOT NULL,
post_excerpt text NOT NULL,
comment_status varchar(20) NOT NULL DEFAULT \'open\',
ping_status varchar(20) NOT NULL DEFAULT \'open\',
post_password varchar(255) NOT NULL DEFAULT \'\',
post_name varchar(200) NOT NULL DEFAULT \'\',
to_ping text NOT NULL,
pinged text NOT NULL,
post_content_filtered longtext NOT NULL,
post_parent bigint(20) unsigned NOT NULL DEFAULT 0,
guid varchar(255) NOT NULL DEFAULT \'\',
menu_order int(11) NOT NULL DEFAULT 0,
post_mime_type varchar(100) NOT NULL DEFAULT \'\',
comment_count bigint(20) NOT NULL DEFAULT 0,
PRIMARY KEY (ID),
KEY post_type (post_type),
KEY post_title (post_title(64))
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY post_id (post_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
}
// ── create() ──────────────────────────────────────────────────────────────
public function test_create_inserts_requested_count_of_products(): void {
$result = WPDO_Post_Stress_Tester::create( 'product', 5 );
$this->assertSame( 5, $result['created'] );
global $wpdb;
$count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_type = 'product'"
);
$this->assertSame( 5, $count );
}
public function test_create_uses_stress_test_prefix_in_post_title(): void {
WPDO_Post_Stress_Tester::create( 'product', 3 );
global $wpdb;
$prefix_count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_title LIKE %s",
WPDO_Post_Stress_Tester::TEST_POST_PREFIX . '%'
)
);
$this->assertSame( 3, $prefix_count );
}
public function test_create_seeds_postmeta_for_each_post(): void {
WPDO_Post_Stress_Tester::create( 'product', 2 );
global $wpdb;
// Each test product should have at least the 5 critical wc_product keys.
$meta_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTMETA . "`"
);
$this->assertGreaterThanOrEqual( 10, $meta_count, 'At least 5 keys × 2 posts = 10 rows.' );
// Verify _price was set on every test product.
$price_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = '_price'"
);
$this->assertSame( 2, $price_count );
}
public function test_create_supports_hp_listing_post_type(): void {
$result = WPDO_Post_Stress_Tester::create( 'hp_listing', 4 );
$this->assertSame( 4, $result['created'] );
global $wpdb;
$count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_type = 'hp_listing'"
);
$this->assertSame( 4, $count );
// hp_listing seeds hp_price, not _price.
$hp_price_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = 'hp_price'"
);
$this->assertSame( 4, $hp_price_count );
}
public function test_create_rejects_unsupported_post_type(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Stress_Tester::create( 'bogus_type', 3 );
}
public function test_create_rejects_zero_count(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Stress_Tester::create( 'product', 0 );
}
public function test_create_rejects_excessive_count(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Stress_Tester::create( 'product', 100001 );
}
// ── count_test_posts() ────────────────────────────────────────────────────
public function test_count_test_posts_returns_zero_for_empty(): void {
$this->assertSame( 0, WPDO_Post_Stress_Tester::count_test_posts() );
}
public function test_count_test_posts_counts_only_stress_prefix(): void {
// Seed 2 stress posts + 1 real post.
WPDO_Post_Stress_Tester::create( 'product', 2 );
global $wpdb;
$wpdb->insert( self::POSTS, array(
'ID' => 9999,
'post_title' => 'Real product not from stress',
'post_type' => 'product',
'post_content' => '',
'post_excerpt' => '',
'post_content_filtered' => '',
'to_ping' => '',
'pinged' => '',
) );
$this->assertSame( 2, WPDO_Post_Stress_Tester::count_test_posts() );
}
// ── cleanup() ─────────────────────────────────────────────────────────────
public function test_cleanup_removes_all_stress_posts_and_their_meta(): void {
WPDO_Post_Stress_Tester::create( 'product', 5 );
// Verify pre-state.
$this->assertSame( 5, WPDO_Post_Stress_Tester::count_test_posts() );
$result = WPDO_Post_Stress_Tester::cleanup();
$this->assertSame( 5, $result['deleted_posts'] );
global $wpdb;
$post_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTS . "`" );
$meta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTMETA . "`" );
$this->assertSame( 0, $post_count );
$this->assertSame( 0, $meta_count, 'cleanup() must cascade delete postmeta.' );
}
public function test_cleanup_preserves_non_stress_posts(): void {
// Real post with prefix-collision-immune title.
global $wpdb;
$wpdb->insert( self::POSTS, array(
'ID' => 9999,
'post_title' => 'Real product not from stress',
'post_type' => 'product',
'post_content' => '',
'post_excerpt' => '',
'post_content_filtered' => '',
'to_ping' => '',
'pinged' => '',
) );
$wpdb->insert( self::POSTMETA, array(
'post_id' => 9999,
'meta_key' => '_price',
'meta_value' => '50.00',
) );
WPDO_Post_Stress_Tester::create( 'product', 3 );
$result = WPDO_Post_Stress_Tester::cleanup();
$this->assertSame( 3, $result['deleted_posts'] );
// Real post + its meta survive.
$remaining_posts = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTS . "`" );
$remaining_meta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTMETA . "`" );
$this->assertSame( 1, $remaining_posts );
$this->assertSame( 1, $remaining_meta );
}
public function test_cleanup_idempotent_on_empty_state(): void {
$result1 = WPDO_Post_Stress_Tester::cleanup();
$result2 = WPDO_Post_Stress_Tester::cleanup();
$this->assertSame( 0, $result1['deleted_posts'] );
$this->assertSame( 0, $result2['deleted_posts'] );
}
// ── v2.11.2: create_realistic() — uses wp_insert_post + update_post_meta ─
public function test_create_realistic_uses_wp_insert_post(): void {
$result = WPDO_Post_Stress_Tester::create_realistic( 'product', 3 );
$this->assertSame( 3, $result['created'] );
$this->assertSame( 'realistic', $result['mode'] ?? '' );
global $wpdb;
$count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_type = 'product'"
);
$this->assertSame( 3, $count );
}
public function test_create_realistic_writes_postmeta_via_update_post_meta(): void {
$result = WPDO_Post_Stress_Tester::create_realistic( 'product', 2 );
$this->assertSame( 2, $result['created'] );
global $wpdb;
$price_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = '_price'"
);
$this->assertSame( 2, $price_count, 'Each realistic product seeds _price.' );
}
public function test_create_realistic_uses_stress_test_prefix(): void {
WPDO_Post_Stress_Tester::create_realistic( 'product', 2 );
global $wpdb;
$prefixed = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_title LIKE %s",
WPDO_Post_Stress_Tester::TEST_POST_PREFIX . '%'
)
);
$this->assertSame( 2, $prefixed );
}
public function test_create_realistic_supports_hp_listing(): void {
$result = WPDO_Post_Stress_Tester::create_realistic( 'hp_listing', 4 );
$this->assertSame( 4, $result['created'] );
global $wpdb;
$hp_price_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = 'hp_price'"
);
$this->assertSame( 4, $hp_price_count );
}
public function test_create_realistic_rejects_unsupported_post_type(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Stress_Tester::create_realistic( 'bogus_type', 2 );
}
public function test_create_realistic_rejects_invalid_count(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Stress_Tester::create_realistic( 'product', 0 );
}
public function test_cleanup_removes_realistic_created_posts(): void {
WPDO_Post_Stress_Tester::create_realistic( 'product', 3 );
$this->assertSame( 3, WPDO_Post_Stress_Tester::count_test_posts() );
$cleanup = WPDO_Post_Stress_Tester::cleanup();
$this->assertSame( 3, $cleanup['deleted_posts'] );
$this->assertSame( 0, WPDO_Post_Stress_Tester::count_test_posts() );
}
// ── v2.11.4: state machine (start / cancel / get_state / get_progress / run_batch) ─
protected function tearDown(): void {
// Reset persisted state between state-machine tests so each test starts idle.
unset( $GLOBALS['_wp_options'][ WPDO_Post_Stress_Tester::OPT_STATE ] );
unset( $GLOBALS['_wp_transients'][ WPDO_Post_Stress_Tester::CANCEL_FLAG ] );
unset( $GLOBALS['_wp_transients']['wpdo_post_stress_pump_lock'] );
}
public function test_get_state_returns_empty_when_idle(): void {
$this->assertSame( array(), WPDO_Post_Stress_Tester::get_state() );
}
public function test_get_progress_returns_idle_when_no_state(): void {
$progress = WPDO_Post_Stress_Tester::get_progress( false );
$this->assertSame( 'idle', $progress['status'] );
$this->assertSame( 0, $progress['processed'] );
}
public function test_start_persists_state_with_running_status(): void {
$result = WPDO_Post_Stress_Tester::start( 'product', 10, 'fast', 5 );
$this->assertTrue( $result['ok'] );
$state = $result['state'];
$this->assertSame( 'running', $state['status'] );
$this->assertSame( 'product', $state['post_type'] );
$this->assertSame( 'fast', $state['mode'] );
$this->assertSame( 10, $state['target'] );
$this->assertSame( 5, $state['batch_size'] );
$this->assertSame( 0, $state['processed'] );
}
public function test_start_rejects_unsupported_post_type(): void {
$result = WPDO_Post_Stress_Tester::start( 'bogus_type', 10 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'unsupported_post_type', $result['error'] );
}
public function test_start_rejects_zero_target(): void {
$result = WPDO_Post_Stress_Tester::start( 'product', 0 );
$this->assertFalse( $result['ok'] );
}
public function test_start_rejects_excessive_target(): void {
$result = WPDO_Post_Stress_Tester::start( 'product', 100001 );
$this->assertFalse( $result['ok'] );
}
public function test_start_rejects_invalid_mode(): void {
$result = WPDO_Post_Stress_Tester::start( 'product', 10, 'turbo' );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'invalid mode', $result['error'] );
}
public function test_start_rejects_concurrent_run(): void {
WPDO_Post_Stress_Tester::start( 'product', 10 );
$result = WPDO_Post_Stress_Tester::start( 'product', 5 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'already_running', $result['error'] );
}
public function test_start_clamps_batch_size_above_max(): void {
$result = WPDO_Post_Stress_Tester::start( 'product', 10, 'fast', 5000 );
$this->assertTrue( $result['ok'] );
$this->assertSame( WPDO_Post_Stress_Tester::MAX_BATCH_SIZE, $result['state']['batch_size'] );
}
public function test_run_batch_advances_processed_count(): void {
WPDO_Post_Stress_Tester::start( 'product', 6, 'fast', 3 );
WPDO_Post_Stress_Tester::run_batch();
$progress = WPDO_Post_Stress_Tester::get_progress( false );
$this->assertSame( 3, $progress['processed'] );
$this->assertSame( 1, $progress['batches_done'] );
$this->assertSame( 'running', $progress['status'] );
WPDO_Post_Stress_Tester::run_batch();
$progress = WPDO_Post_Stress_Tester::get_progress( false );
$this->assertSame( 6, $progress['processed'] );
$this->assertSame( 'completed', $progress['status'] );
}
public function test_run_batch_creates_actual_posts(): void {
WPDO_Post_Stress_Tester::start( 'product', 4, 'fast', 4 );
WPDO_Post_Stress_Tester::run_batch();
$this->assertSame( 4, WPDO_Post_Stress_Tester::count_test_posts() );
}
public function test_run_batch_finalizes_with_benchmark(): void {
WPDO_Post_Stress_Tester::start( 'product', 2, 'fast', 2 );
WPDO_Post_Stress_Tester::run_batch();
$state = WPDO_Post_Stress_Tester::get_state();
$this->assertSame( 'completed', $state['status'] );
$this->assertIsArray( $state['benchmark'] );
$this->assertArrayHasKey( 'write', $state['benchmark'] );
$this->assertArrayHasKey( 'db_sizes', $state['benchmark'] );
$this->assertSame( 'product', $state['benchmark']['post_type'] );
}
public function test_cancel_marks_state_as_cancelled(): void {
WPDO_Post_Stress_Tester::start( 'product', 100, 'fast', 50 );
$result = WPDO_Post_Stress_Tester::cancel();
$this->assertTrue( $result['ok'] );
$this->assertSame( 'cancelled', $result['state']['status'] );
// In-flight batch run after cancel must NOT bump status back to running.
WPDO_Post_Stress_Tester::run_batch();
$state = WPDO_Post_Stress_Tester::get_state();
$this->assertSame( 'cancelled', $state['status'] );
}
public function test_cancel_returns_no_active_job_when_idle(): void {
$result = WPDO_Post_Stress_Tester::cancel();
$this->assertTrue( $result['ok'] );
$this->assertSame( 'no_active_job', $result['message'] ?? '' );
}
public function test_get_progress_includes_pct_and_eta(): void {
WPDO_Post_Stress_Tester::start( 'product', 10, 'fast', 5 );
WPDO_Post_Stress_Tester::run_batch();
$progress = WPDO_Post_Stress_Tester::get_progress( false );
$this->assertArrayHasKey( 'pct', $progress );
$this->assertArrayHasKey( 'rate_per_sec', $progress );
$this->assertArrayHasKey( 'elapsed_sec', $progress );
$this->assertArrayHasKey( 'eta_sec', $progress );
$this->assertArrayHasKey( 'test_post_count', $progress );
$this->assertSame( 50.0, $progress['pct'] ); // 5/10 = 50%
}
public function test_run_benchmark_returns_post_type_aware_query_probes(): void {
WPDO_Post_Stress_Tester::start( 'hp_listing', 4, 'fast', 4 );
WPDO_Post_Stress_Tester::run_batch();
$state = WPDO_Post_Stress_Tester::get_state();
$bench = $state['benchmark'];
$this->assertSame( 'hp_listing', $bench['post_type'] );
// query is post_type-aware; in production the flat table exists so this
// returns 3 probes (point/range/eav_baseline). In integration tests the
// flat table doesn't exist (different prefix), so we only verify the
// structure is present and post_type-aware.
$this->assertIsArray( $bench['query'] );
}
public function test_run_batch_realistic_uses_wp_insert_post(): void {
WPDO_Post_Stress_Tester::start( 'product', 3, 'realistic', 3 );
WPDO_Post_Stress_Tester::run_batch();
$state = WPDO_Post_Stress_Tester::get_state();
$this->assertSame( 'completed', $state['status'] );
$this->assertSame( 3, $state['processed'] );
$this->assertSame( 'realistic', $state['mode'] );
}
}
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Postmeta_Cleaner — wp_postmeta garbage cleanup (v2.9.0).
*
* Verifies count_garbage() and delete_garbage() against real MariaDB:
* - target=transients → meta_key LIKE '_transient_%' OR LIKE '_transient_timeout_%'
* - target=wp_old_date → meta_key = '_wp_old_date'
* - target=edit_locks → meta_key = '_edit_lock' AND lock_ts < now - 86400 (stale)
* - target=all → union of all three
*
* Requires real MariaDB (WPDO_TEST_DB_PASS env var must be set).
*/
class PostmetaCleanerIntegrationTest extends TestCase {
private const POSTMETA = 'wp_itest_postmeta';
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-postmeta-cleaner.php';
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
$wpdb->query(
'CREATE TABLE `' . self::POSTMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY post_id (post_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function seed_postmeta( array $rows ): void {
global $wpdb;
foreach ( $rows as $row ) {
$wpdb->insert( self::POSTMETA, $row );
}
}
// ── count_garbage() ───────────────────────────────────────────────────────
public function test_count_garbage_returns_zero_for_empty_table(): void {
$counts = WPDO_Postmeta_Cleaner::count_garbage( 'all' );
$this->assertSame( 0, $counts['transients'] );
$this->assertSame( 0, $counts['wp_old_date'] );
$this->assertSame( 0, $counts['edit_locks'] );
$this->assertSame( 0, $counts['total'] );
}
public function test_count_garbage_counts_transients(): void {
$this->seed_postmeta( array(
array( 'post_id' => 1, 'meta_key' => '_transient_hp_models/listing/v1', 'meta_value' => 'a' ),
array( 'post_id' => 1, 'meta_key' => '_transient_timeout_hp_models/listing/v1', 'meta_value' => '9999' ),
array( 'post_id' => 2, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
array( 'post_id' => 2, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
) );
$counts = WPDO_Postmeta_Cleaner::count_garbage( 'transients' );
$this->assertSame( 3, $counts['transients'] );
$this->assertSame( 0, $counts['wp_old_date'] );
$this->assertSame( 0, $counts['edit_locks'] );
$this->assertSame( 3, $counts['total'] );
}
public function test_count_garbage_counts_wp_old_date(): void {
$this->seed_postmeta( array(
array( 'post_id' => 1, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ),
array( 'post_id' => 2, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-02-01' ),
array( 'post_id' => 3, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
) );
$counts = WPDO_Postmeta_Cleaner::count_garbage( 'wp_old_date' );
$this->assertSame( 0, $counts['transients'] );
$this->assertSame( 2, $counts['wp_old_date'] );
$this->assertSame( 0, $counts['edit_locks'] );
$this->assertSame( 2, $counts['total'] );
}
public function test_count_garbage_counts_only_stale_edit_locks(): void {
$now = time();
$one_day_ago = $now - 86400 - 60; // stale by 1 day + 1 min
$one_hour_ago = $now - 3600; // fresh, not stale
$five_min_ago = $now - 300; // very fresh, not stale
$this->seed_postmeta( array(
array( 'post_id' => 1, 'meta_key' => '_edit_lock', 'meta_value' => $one_day_ago . ':1' ), // stale ✓
array( 'post_id' => 2, 'meta_key' => '_edit_lock', 'meta_value' => $one_hour_ago . ':2' ), // fresh
array( 'post_id' => 3, 'meta_key' => '_edit_lock', 'meta_value' => $five_min_ago . ':3' ), // fresh
array( 'post_id' => 4, 'meta_key' => '_edit_last', 'meta_value' => '4' ), // not edit_lock
) );
$counts = WPDO_Postmeta_Cleaner::count_garbage( 'edit_locks' );
$this->assertSame( 0, $counts['transients'] );
$this->assertSame( 0, $counts['wp_old_date'] );
$this->assertSame( 1, $counts['edit_locks'], 'Only stale (>24h old) _edit_lock rows count' );
$this->assertSame( 1, $counts['total'] );
}
public function test_count_garbage_target_all_unions_all_three(): void {
$one_day_ago = time() - 86400 - 60;
$this->seed_postmeta( array(
array( 'post_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ),
array( 'post_id' => 2, 'meta_key' => '_transient_timeout_foo', 'meta_value' => '99' ),
array( 'post_id' => 3, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ),
array( 'post_id' => 4, 'meta_key' => '_edit_lock', 'meta_value' => $one_day_ago . ':1' ),
array( 'post_id' => 5, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
) );
$counts = WPDO_Postmeta_Cleaner::count_garbage( 'all' );
$this->assertSame( 2, $counts['transients'] );
$this->assertSame( 1, $counts['wp_old_date'] );
$this->assertSame( 1, $counts['edit_locks'] );
$this->assertSame( 4, $counts['total'] );
}
public function test_count_garbage_rejects_invalid_target(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Postmeta_Cleaner::count_garbage( 'bogus' );
}
// ── delete_garbage() ──────────────────────────────────────────────────────
public function test_delete_garbage_removes_only_target_rows(): void {
$this->seed_postmeta( array(
array( 'post_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ),
array( 'post_id' => 2, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ),
array( 'post_id' => 3, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
) );
$deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'transients' );
$this->assertSame( 1, $deleted['transients'] );
$this->assertSame( 0, $deleted['wp_old_date'] );
$this->assertSame( 0, $deleted['edit_locks'] );
$this->assertSame( 1, $deleted['total'] );
global $wpdb;
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::POSTMETA . '`' );
$this->assertSame( 2, $remaining, 'Non-transient rows should remain (wp_old_date + hp_price)' );
}
public function test_delete_garbage_target_all_clears_all_three(): void {
$one_day_ago = time() - 86400 - 60;
$this->seed_postmeta( array(
array( 'post_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ),
array( 'post_id' => 2, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ),
array( 'post_id' => 3, 'meta_key' => '_edit_lock', 'meta_value' => $one_day_ago . ':1' ),
array( 'post_id' => 4, 'meta_key' => 'hp_price', 'meta_value' => '99' ),
) );
$deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'all' );
$this->assertSame( 1, $deleted['transients'] );
$this->assertSame( 1, $deleted['wp_old_date'] );
$this->assertSame( 1, $deleted['edit_locks'] );
$this->assertSame( 3, $deleted['total'] );
global $wpdb;
$remaining = $wpdb->get_results( 'SELECT meta_key FROM `' . self::POSTMETA . '`', ARRAY_A );
$this->assertCount( 1, $remaining );
$this->assertSame( 'hp_price', $remaining[0]['meta_key'] );
}
public function test_delete_garbage_does_not_touch_fresh_edit_lock(): void {
$one_hour_ago = time() - 3600;
$this->seed_postmeta( array(
array( 'post_id' => 1, 'meta_key' => '_edit_lock', 'meta_value' => $one_hour_ago . ':1' ),
) );
$deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'edit_locks' );
$this->assertSame( 0, $deleted['edit_locks'] );
global $wpdb;
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::POSTMETA . '`' );
$this->assertSame( 1, $remaining, 'Fresh edit_lock must survive cleanup' );
}
public function test_delete_garbage_rejects_invalid_target(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Postmeta_Cleaner::delete_garbage( 'bogus' );
}
}
@@ -0,0 +1,373 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Query_Router SQL generation.
*
* Verifies that pre_get_posts correctly extracts hot-field clauses from
* WP_Query meta_query, and that posts_join / posts_where / posts_groupby
* emit syntactically correct SQL fragments against the integration $wpdb.
*
* No hot table data is written — only SQL string output is asserted.
*/
class QueryRouterIntegrationTest extends TestCase {
private WPDO_Query_Router $router;
// ── Fixture lifecycle ─────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
// Register test fields in Schema Registry (in-memory singleton).
$registry = WPDO_Schema_Registry::instance();
$registry->register( 'test', [
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => 'hp_price',
'indexed' => true,
] );
$registry->register( 'test', [
'post_type' => 'hp_listing',
'meta_key' => 'hp_featured',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_featured',
'indexed' => false,
] );
}
protected function setUp(): void {
$this->router = new WPDO_Query_Router();
// Ensure the hot_hp_listing module is in a query-active state.
WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' );
}
// ── pre_get_posts ─────────────────────────────────────────────────────
public function test_pre_get_posts_extracts_hot_clause(): void {
$query = new WP_Query();
$query->set( 'post_type', 'hp_listing' );
$query->set( 'meta_query', [
[ 'key' => 'hp_price', 'value' => '100', 'compare' => '>=', 'type' => 'DECIMAL' ],
] );
$this->router->pre_get_posts( $query );
$hot = $query->get( 'wpdo_hot_clauses' );
$this->assertIsArray( $hot );
$this->assertArrayHasKey( 'hp_listing', $hot );
$this->assertCount( 1, $hot['hp_listing'] );
$this->assertSame( 'hp_price', $hot['hp_listing'][0]['column'] );
$this->assertSame( '>=', $hot['hp_listing'][0]['compare'] );
$this->assertSame( '100', $hot['hp_listing'][0]['value'] );
// Extracted clause must be removed from meta_query.
$remaining = (array) $query->get( 'meta_query' );
$this->assertEmpty( $remaining );
}
public function test_pre_get_posts_leaves_non_registered_key_in_meta_query(): void {
$query = new WP_Query();
$query->set( 'post_type', 'hp_listing' );
$query->set( 'meta_query', [
[ 'key' => 'hp_price', 'value' => '50', 'compare' => '=' ],
[ 'key' => 'custom_key', 'value' => 'abc', 'compare' => '=' ],
] );
$this->router->pre_get_posts( $query );
$hot = $query->get( 'wpdo_hot_clauses' );
$remaining = (array) $query->get( 'meta_query' );
// hp_price hot-extracted.
$this->assertArrayHasKey( 'hp_listing', $hot );
$this->assertCount( 1, $hot['hp_listing'] );
// custom_key stays in meta_query.
$this->assertCount( 1, $remaining );
$found_keys = array_column( array_values( $remaining ), 'key' );
$this->assertContains( 'custom_key', $found_keys );
}
public function test_pre_get_posts_skips_when_no_meta_query(): void {
$query = new WP_Query();
$query->set( 'post_type', 'hp_listing' );
// No meta_query set.
$this->router->pre_get_posts( $query );
$hot = $query->get( 'wpdo_hot_clauses' );
// Should not have been set at all (get returns default '').
$this->assertEmpty( $hot );
}
public function test_pre_get_posts_skips_when_no_post_type(): void {
$query = new WP_Query();
// No post_type set.
$query->set( 'meta_query', [
[ 'key' => 'hp_price', 'value' => '10', 'compare' => '=' ],
] );
$this->router->pre_get_posts( $query );
$hot = $query->get( 'wpdo_hot_clauses' );
$this->assertEmpty( $hot );
}
public function test_pre_get_posts_skips_inactive_module(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
$query = new WP_Query();
$query->set( 'post_type', 'hp_listing' );
$query->set( 'meta_query', [
[ 'key' => 'hp_price', 'value' => '99', 'compare' => '=' ],
] );
$this->router->pre_get_posts( $query );
$hot = $query->get( 'wpdo_hot_clauses' );
$remaining = (array) $query->get( 'meta_query' );
// No hot clauses extracted.
$this->assertEmpty( $hot );
// Original clause still in meta_query.
$this->assertNotEmpty( $remaining );
}
public function test_pre_get_posts_preserves_relation_in_remaining(): void {
$query = new WP_Query();
$query->set( 'post_type', 'hp_listing' );
$query->set( 'meta_query', [
'relation' => 'AND',
[ 'key' => 'hp_price', 'value' => '50', 'compare' => '>=' ],
[ 'key' => 'custom_key', 'value' => '1', 'compare' => '=' ],
] );
$this->router->pre_get_posts( $query );
$remaining = (array) $query->get( 'meta_query' );
// relation must be preserved because custom_key remains.
$this->assertArrayHasKey( 'relation', $remaining );
$this->assertSame( 'AND', $remaining['relation'] );
}
public function test_pre_get_posts_extracts_multiple_hot_fields(): void {
$query = new WP_Query();
$query->set( 'post_type', 'hp_listing' );
$query->set( 'meta_query', [
[ 'key' => 'hp_price', 'value' => '200', 'compare' => '<=' ],
[ 'key' => 'hp_featured', 'value' => '1', 'compare' => '=' ],
] );
$this->router->pre_get_posts( $query );
$hot = $query->get( 'wpdo_hot_clauses' );
$this->assertCount( 2, $hot['hp_listing'] );
$columns = array_column( $hot['hp_listing'], 'column' );
$this->assertContains( 'hp_price', $columns );
$this->assertContains( 'hp_featured', $columns );
// meta_query fully cleared.
$this->assertEmpty( (array) $query->get( 'meta_query' ) );
}
// ── posts_join ────────────────────────────────────────────────────────
public function test_posts_join_generates_left_join_sql(): void {
$query = new WP_Query();
$query->set( 'wpdo_hot_clauses', [
'hp_listing' => [
[ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '100' ],
],
] );
$join = $this->router->posts_join( '', $query );
$this->assertStringContainsString( 'LEFT JOIN', $join );
// Full physical table name.
$this->assertStringContainsString( 'wp_itest_wpdo_hot_hp_listing', $join );
// Alias.
$this->assertStringContainsString( '`wpdo_hot_hp_listing`', $join );
// posts table join key.
$this->assertStringContainsString( '`wp_itest_posts`', $join );
$this->assertStringContainsString( 'post_id', $join );
}
public function test_posts_join_passthrough_when_no_hot_clauses(): void {
$query = new WP_Query();
$original = ' LEFT JOIN wp_postmeta ON (wp_posts.ID = wp_postmeta.post_id)';
$join = $this->router->posts_join( $original, $query );
$this->assertSame( $original, $join );
}
public function test_posts_join_no_duplicate_join_for_same_alias(): void {
$query = new WP_Query();
$query->set( 'wpdo_hot_clauses', [
'hp_listing' => [
[ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '100' ],
],
] );
// Simulate alias already present in existing join string.
$existing = ' LEFT JOIN `wp_itest_wpdo_hot_hp_listing` AS `wpdo_hot_hp_listing` ON (...)';
$join = $this->router->posts_join( $existing, $query );
// Should appear exactly once.
$this->assertSame( 1, substr_count( $join, '`wpdo_hot_hp_listing`' ) );
}
// ── posts_where ───────────────────────────────────────────────────────
/** Helper: build a WP_Query with pre-set hot_clauses. */
private function query_with_clauses( array $clauses ): WP_Query {
$query = new WP_Query();
$query->set( 'wpdo_hot_clauses', [ 'hp_listing' => $clauses ] );
return $query;
}
public function test_posts_where_equality_condition(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '99.00' ],
] );
$where = $this->router->posts_where( '', $query );
$this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_price`', $where );
$this->assertStringContainsString( '=', $where );
$this->assertStringContainsString( "'99.00'", $where );
}
public function test_posts_where_numeric_type_uses_integer_placeholder(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_featured', 'compare' => '=', 'type' => 'NUMERIC', 'value' => 1 ],
] );
$where = $this->router->posts_where( '', $query );
// %d format — integer value, not quoted.
$this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_featured` = 1', $where );
}
public function test_posts_where_in_condition(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_price', 'compare' => 'IN', 'type' => 'CHAR', 'value' => [ '10.00', '20.00', '30.00' ] ],
] );
$where = $this->router->posts_where( '', $query );
$this->assertStringContainsString( 'IN', $where );
$this->assertStringContainsString( "'10.00'", $where );
$this->assertStringContainsString( "'20.00'", $where );
$this->assertStringContainsString( "'30.00'", $where );
}
public function test_posts_where_in_empty_array_generates_false_condition(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_price', 'compare' => 'IN', 'type' => 'CHAR', 'value' => [] ],
] );
$where = $this->router->posts_where( '', $query );
$this->assertStringContainsString( '1=0', $where );
}
public function test_posts_where_between_condition(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_price', 'compare' => 'BETWEEN', 'type' => 'CHAR', 'value' => [ '10.00', '50.00' ] ],
] );
$where = $this->router->posts_where( '', $query );
$this->assertStringContainsString( 'BETWEEN', $where );
$this->assertStringContainsString( "'10.00'", $where );
$this->assertStringContainsString( "'50.00'", $where );
}
public function test_posts_where_exists_generates_is_not_null(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_price', 'compare' => 'EXISTS', 'type' => 'CHAR', 'value' => '' ],
] );
$where = $this->router->posts_where( '', $query );
$this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_price` IS NOT NULL', $where );
}
public function test_posts_where_not_exists_generates_is_null(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_price', 'compare' => 'NOT EXISTS', 'type' => 'CHAR', 'value' => '' ],
] );
$where = $this->router->posts_where( '', $query );
$this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_price` IS NULL', $where );
}
public function test_posts_where_passthrough_when_no_hot_clauses(): void {
$query = new WP_Query();
$original = ' AND wp_posts.post_status = \'publish\'';
$where = $this->router->posts_where( $original, $query );
$this->assertSame( $original, $where );
}
public function test_posts_where_appends_to_existing_where(): void {
$query = $this->query_with_clauses( [
[ 'column' => 'hp_featured', 'compare' => '=', 'type' => 'NUMERIC', 'value' => 1 ],
] );
$existing = " AND wp_posts.post_status = 'publish'";
$where = $this->router->posts_where( $existing, $query );
$this->assertStringStartsWith( $existing, $where );
$this->assertStringContainsString( 'hp_featured', $where );
}
// ── posts_groupby ─────────────────────────────────────────────────────
public function test_posts_groupby_sets_posts_id_when_empty(): void {
$query = new WP_Query();
$query->set( 'wpdo_hot_clauses', [
'hp_listing' => [
[ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '1' ],
],
] );
$groupby = $this->router->posts_groupby( '', $query );
$this->assertStringContainsString( 'wp_itest_posts', $groupby );
$this->assertStringContainsString( 'ID', $groupby );
}
public function test_posts_groupby_preserves_existing_groupby(): void {
$query = new WP_Query();
$query->set( 'wpdo_hot_clauses', [
'hp_listing' => [
[ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '1' ],
],
] );
$existing = '`wp_itest_posts`.`ID`, `wp_itest_posts`.`post_type`';
$groupby = $this->router->posts_groupby( $existing, $query );
// Existing groupby preserved unchanged (not empty, so no override).
$this->assertSame( $existing, $groupby );
}
public function test_posts_groupby_passthrough_when_no_hot_clauses(): void {
$query = new WP_Query();
$original = '`wp_itest_posts`.`ID`';
$groupby = $this->router->posts_groupby( $original, $query );
$this->assertSame( $original, $groupby );
}
}
@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
/**
* Integration tests for WPDO_REST_API against real MariaDB.
*
* Creates a dedicated wp_itest_wpdo_hot_restapi table, seeds data,
* and exercises all four REST handlers end-to-end.
*/
use PHPUnit\Framework\TestCase;
class RestApiIntegrationTest extends TestCase {
private static WPDO_REST_API $api;
/** Table name for this test suite (avoids collisions with other tests). */
private static string $hot_table;
private static string $warm_table;
public static function setUpBeforeClass(): void {
global $wpdb;
self::$api = new WPDO_REST_API();
self::$hot_table = $wpdb->prefix . 'wpdo_hot_restapi';
self::$warm_table = $wpdb->prefix . 'wpdo_warm';
// Register fields for the fake 'restapi' post type.
$registry = WPDO_Schema_Registry::instance();
$registry->register( 'integration_rest', [
'post_type' => 'restapi',
'meta_key' => 'rp_price',
'zone' => 'hot',
'column' => 'rp_price',
'type' => 'decimal',
] );
$registry->register( 'integration_rest', [
'post_type' => 'restapi',
'meta_key' => 'rp_featured',
'zone' => 'hot',
'column' => 'rp_featured',
'type' => 'tinyint',
] );
$registry->register( 'integration_rest', [
'post_type' => 'restapi',
'meta_key' => 'rp_description',
'zone' => 'cold',
] );
// Create hot table.
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `" . self::$hot_table . "` (
post_id BIGINT UNSIGNED NOT NULL,
rp_price DECIMAL(10,2) DEFAULT NULL,
rp_featured TINYINT(1) DEFAULT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Create warm table (needed by WPDO_Listing_Stats::get_view_count).
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `" . self::$warm_table . "` (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
meta_key VARCHAR(255) NOT NULL,
meta_value LONGTEXT,
expires_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY post_meta (post_id, meta_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Seed 5 rows.
for ( $i = 1; $i <= 5; $i++ ) {
$price = $i * 100;
$featured = $i % 2;
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES ($i, $price, $featured)" );
}
// Set module to cutover so REST API reads from Zone A.
WPDO_Feature_Flags::set( 'hot_restapi', 'cutover' );
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$hot_table . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
WPDO_Feature_Flags::reset( 'hot_restapi' );
}
protected function setUp(): void {
$GLOBALS['_wp_cache'] = [];
$GLOBALS['_wp_post_types'] = [];
$GLOBALS['_wp_postmeta'] = [];
$GLOBALS['_wp_current_user_can'] = [];
$GLOBALS['_wp_valid_nonces'] = [];
$GLOBALS['_wp_transients'] = []; // Reset rate-limit transients between tests.
// Note: do NOT reset _wp_options here — Feature Flags state is stored there.
// Invalidate Feature Flags static request cache so each test reads fresh.
$ref = new ReflectionClass( WPDO_Feature_Flags::class );
$prop = $ref->getProperty( 'cache' );
$prop->setAccessible( true );
$prop->setValue( null, null );
}
// ── GET /listings (Zone A path) ──────────────────────────────────────────
public function test_listings_returns_all_rows(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'per_page', 10 );
$response = self::$api->get_listings( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertCount( 5, $data );
$this->assertSame( '5', $response->get_headers()['X-WP-Total'] );
}
public function test_listings_returns_correct_fields(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'per_page', 1 );
$req->set_param( 'orderby', 'post_id' );
$req->set_param( 'order', 'ASC' );
$response = self::$api->get_listings( $req );
$data = $response->get_data();
$this->assertSame( 1, $data[0]['id'] );
$this->assertSame( 'restapi', $data[0]['post_type'] );
$this->assertArrayHasKey( 'rp_price', $data[0] );
$this->assertArrayHasKey( 'rp_featured', $data[0] );
$this->assertArrayNotHasKey( 'post_id', $data[0] );
$this->assertArrayNotHasKey( 'updated_at', $data[0] );
}
public function test_listings_pagination(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'per_page', 2 );
$req->set_param( 'page', 2 );
$req->set_param( 'orderby', 'post_id' );
$req->set_param( 'order', 'ASC' );
$response = self::$api->get_listings( $req );
$data = $response->get_data();
$this->assertCount( 2, $data );
$this->assertSame( 3, $data[0]['id'] ); // page 2 offset 2 → post_id 3
$this->assertSame( '3', $response->get_headers()['X-WP-TotalPages'] );
}
public function test_listings_per_page_clamped_to_max_100(): void {
// PR-0 R-4: defense-in-depth — per_page=99999 must clamp to 100, not DoS the DB.
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'per_page', 99999 );
$response = self::$api->get_listings( $req );
// Should return at most 100 items (real dataset is 5 — capped by total).
$data = $response->get_data();
$this->assertLessThanOrEqual( 100, count( $data ) );
}
public function test_listings_per_page_max_filter_overridable(): void {
// PR-0 R-4: site owners can lower the cap via wpdo_rest_max_per_page filter.
// We can't fully test add_filter() in this stubbed env, but we verify the
// constant value is correctly read in the code path (above test exercises 100).
$this->assertTrue( true );
}
public function test_listings_filter_price_min(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'rp_price_min', 300 );
$response = self::$api->get_listings( $req );
$data = $response->get_data();
// Prices are 100,200,300,400,500 → ≥300 = 3 rows.
$this->assertSame( '3', $response->get_headers()['X-WP-Total'] );
foreach ( $data as $item ) {
$this->assertGreaterThanOrEqual( 300.0, (float) $item['rp_price'] );
}
}
public function test_listings_filter_price_range(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'rp_price_min', 200 );
$req->set_param( 'rp_price_max', 400 );
$response = self::$api->get_listings( $req );
$this->assertSame( '3', $response->get_headers()['X-WP-Total'] );
}
public function test_listings_filter_exact_value(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'rp_featured', 1 );
$response = self::$api->get_listings( $req );
$data = $response->get_data();
// featured=1 for post_id 1,3,5 → 3 rows.
$this->assertSame( '3', $response->get_headers()['X-WP-Total'] );
foreach ( $data as $item ) {
$this->assertSame( '1', (string) $item['rp_featured'] );
}
}
public function test_listings_order_asc(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'orderby', 'rp_price' );
$req->set_param( 'order', 'ASC' );
$req->set_param( 'per_page', 5 );
$response = self::$api->get_listings( $req );
$data = $response->get_data();
$prices = array_column( $data, 'rp_price' );
$sorted = $prices;
sort( $sorted );
$this->assertSame( $sorted, $prices );
}
// ── GET /listings/{id} ───────────────────────────────────────────────────
public function test_get_listing_404_for_unknown_post(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/9999' );
$req->set_param( 'id', 9999 );
$response = self::$api->get_listing( $req );
$this->assertSame( 404, $response->get_status() );
}
public function test_get_listing_merges_hot_and_postmeta_cold(): void {
// post_id 1 is in hot table (rp_price=100); cold zone idle → postmeta fallback.
$GLOBALS['_wp_post_types'][1] = 'restapi';
$GLOBALS['_wp_postmeta'][1]['rp_description'] = 'Integration test';
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/1' );
$req->set_param( 'id', 1 );
$response = self::$api->get_listing( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertSame( 1, $data['id'] );
$this->assertSame( '100.00', $data['rp_price'] );
$this->assertSame( 'Integration test', $data['rp_description'] );
}
// ── GET /stats/{id} ──────────────────────────────────────────────────────
public function test_get_stats_returns_zero_for_unknown_post(): void {
$GLOBALS['_wp_post_types'][77] = 'restapi';
$req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/77' );
$req->set_param( 'id', 77 );
$response = self::$api->get_stats( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertSame( 77, $data['post_id'] );
$this->assertSame( 0, $data['view_count'] );
}
// ── GET /status ──────────────────────────────────────────────────────────
public function test_get_status_requires_manage_options(): void {
$GLOBALS['_wp_current_user_can']['manage_options'] = false;
$this->assertFalse( self::$api->require_manage_options() );
}
public function test_get_status_returns_correct_engine(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/status' );
$response = self::$api->get_status( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertSame( 'mysql', $data['engine'] );
$this->assertArrayHasKey( 'modules', $data );
$this->assertSame( 'cutover', $data['modules']['hot_restapi'] );
}
// ── POST /listings/{id}/view ──────────────────────────────────────────────
public function test_post_view_403_without_nonce(): void {
$GLOBALS['_wp_post_types'][1] = 'restapi';
$GLOBALS['_wp_valid_nonces'] = [];
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/1/view' );
$req->set_param( 'id', 1 );
// No nonce.
$response = self::$api->post_view( $req );
$this->assertSame( 403, $response->get_status() );
}
public function test_post_view_404_for_unknown_post(): void {
$nonce = wp_create_nonce( 'wp_rest' );
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/8888/view' );
$req->set_param( 'id', 8888 );
$req->set_header( 'X-WP-Nonce', $nonce );
$response = self::$api->post_view( $req );
$this->assertSame( 404, $response->get_status() );
}
}
@@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Sync_Bridge entity-bridge guard (v2.9.2).
*
* Verifies that when WPDO_Entity_Registry has registered a meta_key for
* entity_type=post AND post mode is dual_write or higher, Sync_Bridge
* skips its zone write so the same value isn't written to two flat tables.
*
* This is the central correctness guarantee for v2.9.2 — without it, the
* 5 keys overlapping between Schema_Registry hot zone and the new post
* Entity Registry (hp_price, hp_featured, hp_verified, _price, _stock)
* would receive triple writes (zone + entity flat + wp_postmeta).
*
* Behavior matrix:
* post mode = disabled → Sync_Bridge writes zone (legacy unchanged)
* post mode = dual_write+ + key in Entity_Registry → Sync_Bridge skips zone
* post mode = dual_write+ + key NOT in Entity_Registry → Sync_Bridge writes zone (back-compat)
*/
class SyncBridgeEntityGuardTest extends TestCase {
private const POST_TYPE = 'hp_listing';
private const TABLE = 'wp_itest_wpdo_hot_hp_listing';
private const MODULE = 'hot_hp_listing';
private const ENTITY_KEY = 'hp_price'; // overlaps Entity_Registry hp_listing_core
private const ZONE_ONLY_KEY = 'hp_legacy_only'; // only in Schema_Registry, not Entity_Registry
private WPDO_Sync_Bridge $bridge;
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
// Load Entity_Registry chain (interface → adapter → registry → mode-manager).
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/interface-entity-adapter.php';
}
if ( ! class_exists( 'WPDO_Entity_Registry' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/engine/class-tmdo-entity-registry.php';
}
if ( ! class_exists( 'WPDO_Mode_Manager' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/engine/class-tmdo-mode-manager.php';
}
if ( ! class_exists( 'WPDO_Adapter_Post' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/class-tmdo-adapter-post.php';
}
if ( ! class_exists( 'WPDO_Post_Fields' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-post-fields.php';
}
// Hot zone test table (legacy Sync_Bridge target).
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`hp_price` decimal(10,2) DEFAULT NULL,
`hp_legacy_only` varchar(255) DEFAULT NULL,
`updated_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
PRIMARY KEY (`id`),
UNIQUE KEY `post_id` (`post_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' );
$wpdb->query(
'CREATE TABLE `wp_itest_wpdo_errors` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`module` varchar(100) NOT NULL DEFAULT \'\',
`zone` varchar(20) NOT NULL DEFAULT \'\',
`hook` varchar(255) NOT NULL DEFAULT \'\',
`message` text NOT NULL,
`context` longtext,
`created_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
// Reset Schema_Registry singleton + register both keys (one will overlap with Entity_Registry).
$ref = new ReflectionClass( WPDO_Schema_Registry::class );
$inst = $ref->getProperty( 'instance' );
$inst->setAccessible( true );
$inst->setValue( null, null );
WPDO_Schema_Registry::instance()->register( 'test', array(
'post_type' => self::POST_TYPE,
'meta_key' => self::ENTITY_KEY,
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => self::ENTITY_KEY,
'indexed' => false,
) );
WPDO_Schema_Registry::instance()->register( 'test', array(
'post_type' => self::POST_TYPE,
'meta_key' => self::ZONE_ONLY_KEY,
'zone' => 'hot',
'data_type' => 'varchar(255) DEFAULT NULL',
'column' => self::ZONE_ONLY_KEY,
'indexed' => false,
) );
// Register post adapter + post-fields groups (puts hp_price into Entity_Registry).
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' );
// Reset Mode_Manager cache to prevent post=dual_write leaking into
// later tests that share the same PHP process (e.g. SyncBridgeIntegrationTest
// which uses 'hp_price' as a generic test field — that key is in the post
// Entity_Registry once we've registered it here, so the guard would fire
// in those tests' assertions if mode is still cached as dual_write).
$ref = new ReflectionClass( WPDO_Mode_Manager::class );
$cache = $ref->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, null );
// Also reset Entity_Registry so the registered post groups don't leak.
WPDO_Entity_Registry::init();
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
$GLOBALS['_wp_options'] = array();
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
// Reset Sync_Bridge state.
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
$cache = $ref->getProperty( 'field_cache' );
$cache->setAccessible( true );
$cache->setValue( null, array() );
$bypass = $ref->getProperty( 'bypassing' );
$bypass->setAccessible( true );
$bypass->setValue( null, false );
// Reset Mode_Manager cache to default (post=disabled).
// Tests that need dual_write override via set_post_mode() helper below,
// which writes the cache directly (avoiding Cache_Orchestrator dep).
self::set_post_mode( 'disabled' );
// Seed post-type lookup.
$GLOBALS['_wp_post_types'] = array();
for ( $i = 1; $i <= 20; $i++ ) {
$GLOBALS['_wp_post_types'][ $i ] = self::POST_TYPE;
}
$GLOBALS['_wp_cache'] = array();
$this->bridge = new WPDO_Sync_Bridge();
}
/**
* Set Mode_Manager post mode by writing the static cache directly,
* bypassing set() which has a hard dep on WPDO_Cache_Orchestrator
* (out of scope for this guard test).
*/
private static function set_post_mode( string $mode ): void {
$ref = new ReflectionClass( WPDO_Mode_Manager::class );
$cache = $ref->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, array(
'post' => $mode,
'user' => 'aeav_only', // user mode frozen — must not change
'term' => 'dual_write',
'comment' => 'dual_write',
) );
}
// ── Tests ─────────────────────────────────────────────────────────────────
/**
* Baseline: post mode = disabled (default) — Sync_Bridge MUST still write zone.
* This guarantees v2.9.1 → v2.9.2 upgrade is zero-impact for users who
* haven't opted in to Entity Bridge post mode.
*/
public function test_zone_write_unchanged_when_post_mode_disabled(): void {
// post mode defaults to disabled — Mode_Manager reads from option.
$this->bridge->intercept_update( null, 1, self::ENTITY_KEY, '199.99', '' );
$val = WPDO_Zone_Hot::get( 1, self::POST_TYPE, self::ENTITY_KEY );
$this->assertSame(
'199.99',
$val,
'mode=disabled: Sync_Bridge must continue writing zone (legacy back-compat).'
);
}
/**
* Guard: post mode = dual_write + key registered in Entity_Registry
* → Sync_Bridge skips zone write (Entity Bridge will handle it).
*/
public function test_zone_skipped_when_post_mode_dual_write_and_key_in_entity_registry(): void {
self::set_post_mode( 'dual_write' );
$this->bridge->intercept_update( null, 2, self::ENTITY_KEY, '299.99', '' );
$val = WPDO_Zone_Hot::get( 2, self::POST_TYPE, self::ENTITY_KEY );
$this->assertNull(
$val,
'mode=dual_write + Entity_Registry has key: Sync_Bridge MUST skip zone write to avoid double-write.'
);
}
/**
* Back-compat: post mode = dual_write + key NOT in Entity_Registry
* → Sync_Bridge still writes zone (only Entity_Registry-managed keys are skipped).
*/
public function test_zone_write_continues_for_zone_only_key_when_post_mode_dual_write(): void {
self::set_post_mode( 'dual_write' );
// hp_legacy_only is in Schema_Registry only — not in Entity_Registry.
$this->bridge->intercept_update( null, 3, self::ZONE_ONLY_KEY, 'legacy_value', '' );
$val = WPDO_Zone_Hot::get( 3, self::POST_TYPE, self::ZONE_ONLY_KEY );
$this->assertSame(
'legacy_value',
$val,
'mode=dual_write but key not in Entity_Registry: Sync_Bridge must keep writing zone (back-compat).'
);
}
/**
* The intercept_update return value must remain null in all branches —
* we never short-circuit WP native postmeta in v2.9.2 (still dual_write
* w.r.t. wp_postmeta; cutover comes in v2.9.5).
*/
public function test_intercept_returns_null_regardless_of_guard(): void {
self::set_post_mode( 'dual_write' );
$result_skipped = $this->bridge->intercept_update( null, 4, self::ENTITY_KEY, '50.00', '' );
$result_written = $this->bridge->intercept_update( null, 5, self::ZONE_ONLY_KEY, 'x', '' );
$this->assertNull( $result_skipped, 'Guard branch must still return null.' );
$this->assertNull( $result_written, 'Non-guard branch must still return null.' );
}
/**
* intercept_add must apply the same guard.
*/
public function test_add_zone_skipped_when_entity_registry_owns_key(): void {
self::set_post_mode( 'dual_write' );
$this->bridge->intercept_add( null, 6, self::ENTITY_KEY, '99.99', true );
$val = WPDO_Zone_Hot::get( 6, self::POST_TYPE, self::ENTITY_KEY );
$this->assertNull(
$val,
'intercept_add must apply the Entity_Registry guard symmetrically with intercept_update.'
);
}
}
@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Sync_Bridge against real MariaDB.
*
* Validates the full dual-write path:
* intercept_update / intercept_add → Zone Hot table written
* intercept_get → Zone Hot table read when module in read-custom state
* intercept_delete → Zone Hot column zeroed out
*
* Uses a dedicated test post type `test_post` and table `wp_itest_wpdo_hot_test_post`.
* get_post_type() is driven by $GLOBALS['_wp_post_types'] set in each test.
*/
class SyncBridgeIntegrationTest extends TestCase {
private const POST_TYPE = 'test_post';
private const TABLE = 'wp_itest_wpdo_hot_test_post';
private const MODULE = 'hot_test_post'; // WPDO_Sync_Bridge::get_zone_module('hot', 'test_post')
private const FIELD = 'hp_price';
private WPDO_Sync_Bridge $bridge;
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
// DROP + CREATE ensures clean schema even after interrupted prior runs.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`hp_price` decimal(10,2) DEFAULT NULL,
`updated_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
PRIMARY KEY (`id`),
UNIQUE KEY `post_id` (`post_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
// Create the errors log table so WPDO_Logger::error() can write to it.
$wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' );
$wpdb->query(
'CREATE TABLE `wp_itest_wpdo_errors` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`module` varchar(100) NOT NULL DEFAULT \'\',
`zone` varchar(20) NOT NULL DEFAULT \'\',
`hook` varchar(255) NOT NULL DEFAULT \'\',
`message` text NOT NULL,
`context` longtext,
`created_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
// Reset and populate the Schema Registry singleton.
$ref = new ReflectionClass( WPDO_Schema_Registry::class );
$inst = $ref->getProperty( 'instance' );
$inst->setAccessible( true );
$inst->setValue( null, null );
WPDO_Schema_Registry::instance()->register( 'test_provider', [
'post_type' => self::POST_TYPE,
'meta_key' => self::FIELD,
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => self::FIELD,
'indexed' => false,
] );
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' );
}
protected function setUp(): void {
global $wpdb;
// Wipe data before each test.
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
// Reset FeatureFlags (clears option + static cache).
$GLOBALS['_wp_options'] = [];
WPDO_Feature_Flags::set( self::MODULE, 'idle' );
// Reset SyncBridge request-level field cache.
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
$cache = $ref->getProperty( 'field_cache' );
$cache->setAccessible( true );
$cache->setValue( null, [] );
// Reset $bypassing flag.
$bypass = $ref->getProperty( 'bypassing' );
$bypass->setAccessible( true );
$bypass->setValue( null, false );
// Seed post-type lookup.
$GLOBALS['_wp_post_types'] = [];
for ( $i = 1; $i <= 20; $i++ ) {
$GLOBALS['_wp_post_types'][ $i ] = self::POST_TYPE;
}
// Object cache reset.
$GLOBALS['_wp_cache'] = [];
$this->bridge = new WPDO_Sync_Bridge();
}
// ── intercept_update ─────────────────────────────────────────────────────
public function test_update_dual_write_writes_value_to_hot_table(): void {
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
$this->bridge->intercept_update( null, 1, self::FIELD, '199.99', '' );
$val = WPDO_Zone_Hot::get( 1, self::POST_TYPE, self::FIELD );
$this->assertSame( '199.99', $val );
}
public function test_update_idle_does_not_write_to_hot_table(): void {
// Module stays in 'idle' — is_write_active() returns false.
$this->bridge->intercept_update( null, 2, self::FIELD, '50.00', '' );
$val = WPDO_Zone_Hot::get( 2, self::POST_TYPE, self::FIELD );
$this->assertNull( $val );
}
public function test_update_always_returns_null_to_allow_native_write(): void {
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
$result = $this->bridge->intercept_update( null, 3, self::FIELD, '100.00', '' );
// Must return null (not short-circuit) so WordPress still writes postmeta.
$this->assertNull( $result );
}
public function test_update_skips_unregistered_meta_key(): void {
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
// 'hp_unregistered' is not in Schema Registry.
$this->bridge->intercept_update( null, 4, 'hp_unregistered', '42.00', '' );
// Hot table for test_post should still be empty.
$val = WPDO_Zone_Hot::get( 4, self::POST_TYPE, self::FIELD );
$this->assertNull( $val );
}
public function test_update_skips_when_post_type_unknown(): void {
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
// post_id 999 not seeded in _wp_post_types → get_post_type() returns false.
$this->bridge->intercept_update( null, 999, self::FIELD, '77.00', '' );
// Nothing should have been written (table doesn't have post 999).
$val = WPDO_Zone_Hot::get( 999, self::POST_TYPE, self::FIELD );
$this->assertNull( $val );
}
// ── intercept_add ────────────────────────────────────────────────────────
public function test_add_dual_write_writes_value_to_hot_table(): void {
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
$this->bridge->intercept_add( null, 5, self::FIELD, '299.00', true );
$val = WPDO_Zone_Hot::get( 5, self::POST_TYPE, self::FIELD );
$this->assertSame( '299.00', $val );
}
public function test_add_returns_null_to_allow_native_write(): void {
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
$result = $this->bridge->intercept_add( null, 6, self::FIELD, '10.00', false );
$this->assertNull( $result );
}
// ── intercept_get ────────────────────────────────────────────────────────
public function test_get_cutover_returns_zone_value_wrapped_in_array(): void {
// Write directly to hot table, then verify intercept_get reads it back.
WPDO_Zone_Hot::set( 7, self::POST_TYPE, self::FIELD, '500.00' );
WPDO_Feature_Flags::set( self::MODULE, 'cutover' );
$result = $this->bridge->intercept_get( null, 7, self::FIELD, true );
// SyncBridge wraps value in array so WP can unwrap correctly.
$this->assertIsArray( $result );
$this->assertSame( '500.00', $result[0] );
}
public function test_get_dual_write_returns_null_passthrough(): void {
WPDO_Zone_Hot::set( 8, self::POST_TYPE, self::FIELD, '123.00' );
// dual_write is NOT a read-custom state.
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
$result = $this->bridge->intercept_get( null, 8, self::FIELD, true );
// Should pass through (return null) so WP reads from postmeta.
$this->assertNull( $result );
}
public function test_get_returns_null_when_no_zone_row(): void {
// cutover state but no row in hot table.
WPDO_Feature_Flags::set( self::MODULE, 'cutover' );
$result = $this->bridge->intercept_get( null, 9, self::FIELD, true );
$this->assertNull( $result );
}
public function test_get_returns_null_for_empty_meta_key(): void {
WPDO_Feature_Flags::set( self::MODULE, 'cutover' );
// Empty meta_key means "get all meta" — bridge should pass through.
$result = $this->bridge->intercept_get( null, 10, '', true );
$this->assertNull( $result );
}
// ── intercept_delete ─────────────────────────────────────────────────────
public function test_delete_zeros_out_hot_column(): void {
WPDO_Zone_Hot::set( 11, self::POST_TYPE, self::FIELD, '999.00' );
$this->assertSame( '999.00', WPDO_Zone_Hot::get( 11, self::POST_TYPE, self::FIELD ) );
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
$this->bridge->intercept_delete( [ 1 ], 11, self::FIELD, '999.00' );
// delete_from_zone calls Zone_Hot::set(post_id, post_type, column, null).
$val = WPDO_Zone_Hot::get( 11, self::POST_TYPE, self::FIELD );
$this->assertNull( $val );
}
// ── $bypassing flag ───────────────────────────────────────────────────────
public function test_bypass_flag_prevents_intercept_get(): void {
WPDO_Zone_Hot::set( 12, self::POST_TYPE, self::FIELD, '777.00' );
WPDO_Feature_Flags::set( self::MODULE, 'cutover' );
// Simulate internal call (e.g. migration reading postmeta).
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
$bypass = $ref->getProperty( 'bypassing' );
$bypass->setAccessible( true );
$bypass->setValue( null, true );
$result = $this->bridge->intercept_get( null, 12, self::FIELD, true );
// Should pass through immediately, ignoring zone.
$this->assertNull( $result );
}
public function test_bypass_flag_prevents_intercept_update(): void {
WPDO_Feature_Flags::set( self::MODULE, 'dual_write' );
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
$bypass = $ref->getProperty( 'bypassing' );
$bypass->setAccessible( true );
$bypass->setValue( null, true );
$this->bridge->intercept_update( null, 13, self::FIELD, '888.00', '' );
// bypassing = true → no write to hot table.
$val = WPDO_Zone_Hot::get( 13, self::POST_TYPE, self::FIELD );
$this->assertNull( $val );
}
}
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Term_Comment_Garbage_Filter (v2.12.1).
*
* Tests the metadata filter callbacks directly with synthetic args.
* Live WP add_filter / get_term_meta wiring is exercised by a dev10
* smoke test (see CHANGELOG); these unit-style integration tests focus
* on the pure logic of pattern matching + counter behavior.
*/
class TermCommentGarbageFilterTest extends TestCase {
public static function setUpBeforeClass(): void {
if ( ! class_exists( 'WPDO_Term_Comment_Garbage_Filter' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-term-comment-garbage-filter.php';
}
}
protected function setUp(): void {
// Reset wp_options state for clean per-test counter behavior.
$GLOBALS['_wp_options'] = array();
update_option( WPDO_Term_Comment_Garbage_Filter::OPT_ENABLED, '1' );
}
// ── is_shared_garbage_key ────────────────────────────────────────────────
public function test_is_shared_garbage_key_matches_wxr_import(): void {
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_import_user' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_import_post' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_import_term' ) );
}
public function test_is_shared_garbage_key_matches_2meet_demo(): void {
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_2meet_demo_music' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_2meet_demo_adv' ) );
}
public function test_is_shared_garbage_key_rejects_legitimate_keys(): void {
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'hp_sort_order' ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'hp_default' ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'hp_rating' ) );
}
public function test_is_shared_garbage_key_rejects_partial_match(): void {
// Substring matches should NOT trigger.
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'something_wxr_import_' ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_imp' ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_2meet_demos' ) );
}
public function test_is_shared_garbage_key_rejects_non_string(): void {
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( null ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 123 ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( array() ) );
}
// ── is_comment_orphan_key ────────────────────────────────────────────────
public function test_is_comment_orphan_key_matches_post_domain_keys(): void {
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_price' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_status' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_featured' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_verified' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_view_count' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_thumbnail_id' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_edit_lock' ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_edit_last' ) );
}
public function test_is_comment_orphan_key_rejects_legitimate_comment_keys(): void {
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( 'hp_rating' ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( 'note_group' ) );
}
public function test_is_comment_orphan_key_requires_exact_match(): void {
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_price_extended' ) );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_pric' ) );
}
// ── on_term_write callback ───────────────────────────────────────────────
public function test_on_term_write_drops_garbage_keys(): void {
$result = WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_wxr_import_user', 'val', false );
$this->assertTrue( $result, 'garbage write must short-circuit (return true)' );
}
public function test_on_term_write_passes_through_legitimate_keys(): void {
$result = WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, 'hp_sort_order', '5', false );
$this->assertNull( $result, 'legitimate write must fall through (return null)' );
}
public function test_on_term_write_does_not_apply_comment_orphan_rules(): void {
// _hp_price is comment-only orphan; for term writes it must pass through
$result = WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_hp_price', '99', false );
$this->assertNull( $result );
}
// ── on_comment_write callback ────────────────────────────────────────────
public function test_on_comment_write_drops_shared_garbage(): void {
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_wxr_import_user', 'a', false ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_2meet_demo_music', '1', false ) );
}
public function test_on_comment_write_drops_orphan_post_meta(): void {
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_hp_price', '99', false ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_thumbnail_id', '50', false ) );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_edit_lock', '111:1', false ) );
}
public function test_on_comment_write_passes_through_legitimate_keys(): void {
$this->assertNull( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, 'hp_rating', '5', false ) );
$this->assertNull( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, 'note_group', 'foo', false ) );
}
// ── 24h drop counter ────────────────────────────────────────────────────
public function test_drop_counter_starts_at_zero(): void {
$this->assertSame( 0, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
}
public function test_drop_counter_increments_on_each_drop(): void {
WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_wxr_import_user', 'a', false );
WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 2, '_2meet_demo_music', '1', false );
WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 3, '_hp_price', '99', false );
$this->assertSame( 3, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
}
public function test_drop_counter_does_not_increment_on_legitimate_writes(): void {
WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, 'hp_sort_order', '5', false );
WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 2, 'hp_rating', '5', false );
$this->assertSame( 0, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
}
public function test_drop_counter_resets_after_24h(): void {
// Simulate counter from 25h ago
update_option( WPDO_Term_Comment_Garbage_Filter::OPT_DROPPED_COUNT, 100 );
update_option( WPDO_Term_Comment_Garbage_Filter::OPT_DROPPED_RESET_AT, time() - 25 * HOUR_IN_SECONDS );
// get_drop_count_24h returns 0 (auto-reset semantics)
$this->assertSame( 0, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
// First new drop after expiry resets counter to 1
WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_wxr_import_user', 'a', false );
$this->assertSame( 1, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() );
}
// ── is_enabled toggle ───────────────────────────────────────────────────
public function test_is_enabled_defaults_true(): void {
delete_option( WPDO_Term_Comment_Garbage_Filter::OPT_ENABLED );
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_enabled() );
}
public function test_is_enabled_respects_zero_value(): void {
update_option( WPDO_Term_Comment_Garbage_Filter::OPT_ENABLED, '0' );
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_enabled() );
}
}
@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Term_Comment_Misc_Bucket (v2.12.4 Phase 4).
*
* Verifies the priority-99 catch-all behavior:
* - on_*_read returns $pre when $pre !== null (preserves earlier filter result)
* - on_*_write returns $check when $check !== null (preserves earlier short-circuit)
* - Otherwise: write/read goes to wp_wpdo_term_misc / wp_wpdo_comment_misc
* - UPSERT semantics on PRIMARY KEY (entity_id, meta_key)
* - Round-trip: write → read returns same value
* - Delete clears the row
*/
class TermCommentMiscBucketTest extends TestCase {
private const TERM_MISC = 'wp_itest_wpdo_term_misc';
private const COMMENT_MISC = 'wp_itest_wpdo_comment_misc';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Term_Comment_Misc_Bucket' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-term-comment-misc-bucket.php';
}
// Override $wpdb->prefix's resolution by creating tables under the
// itest prefix and shadowing term_table()/comment_table() via $wpdb->prefix.
// $wpdb->prefix is 'wp_itest_' in tests so wpdo_term_misc resolves to wp_itest_wpdo_term_misc.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_MISC . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TERM_MISC . '` (
term_id bigint(20) unsigned NOT NULL,
meta_key varchar(191) NOT NULL,
meta_value longtext,
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (term_id, meta_key),
KEY meta_key (meta_key)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENT_MISC . '`' );
$wpdb->query(
'CREATE TABLE `' . self::COMMENT_MISC . '` (
comment_id bigint(20) unsigned NOT NULL,
meta_key varchar(191) NOT NULL,
meta_value longtext,
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (comment_id, meta_key),
KEY meta_key (meta_key)
) DEFAULT CHARACTER SET utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_MISC . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENT_MISC . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TERM_MISC . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::COMMENT_MISC . '`' );
}
// ── $pre preservation contract (priority chain integrity) ────────────────
public function test_on_term_read_preserves_non_null_pre(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_term_read( array( 'managed_value' ), 1, 'any_key', true );
$this->assertSame( array( 'managed_value' ), $result );
}
public function test_on_term_add_preserves_non_null_check(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_term_add( true, 1, 'any_key', 'value', false );
$this->assertTrue( $result, 'Must preserve $check=true (someone else handled write)' );
}
public function test_on_term_update_preserves_non_null_check(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_term_update( true, 1, 'any_key', 'value', '' );
$this->assertTrue( $result );
}
public function test_on_term_delete_preserves_non_null_check(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_term_delete( true, 1, 'any_key', '', false );
$this->assertTrue( $result );
}
public function test_comment_callbacks_preserve_non_null_check(): void {
$this->assertTrue( WPDO_Term_Comment_Misc_Bucket::on_comment_add( true, 1, 'any', 'v', false ) );
$this->assertTrue( WPDO_Term_Comment_Misc_Bucket::on_comment_update( true, 1, 'any', 'v', '' ) );
$this->assertTrue( WPDO_Term_Comment_Misc_Bucket::on_comment_delete( true, 1, 'any', '', false ) );
$this->assertSame( array( 'v' ), WPDO_Term_Comment_Misc_Bucket::on_comment_read( array( 'v' ), 1, 'any', true ) );
}
// ── Catch-all behavior when $check === null ─────────────────────────────
public function test_on_term_add_writes_to_misc_table_when_unhandled(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_term_add( null, 5, 'note_group', 'foo', false );
$this->assertTrue( $result, 'Must short-circuit (return true) after writing' );
global $wpdb;
$value = $wpdb->get_var(
$wpdb->prepare(
'SELECT meta_value FROM `' . self::TERM_MISC . '` WHERE term_id = %d AND meta_key = %s',
5,
'note_group'
)
);
$this->assertSame( 'foo', $value );
}
public function test_on_term_read_returns_value_from_misc_table_when_unhandled(): void {
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 7, 'unknown_key', 'bar', '' );
$result = WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 7, 'unknown_key', true );
$this->assertSame( array( 'bar' ), $result );
}
public function test_on_term_read_returns_pre_on_cache_miss(): void {
// No prior write — read should fall through (return $pre = null, letting WP query DB).
$result = WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 999, 'never_written', true );
$this->assertNull( $result );
}
public function test_upsert_replaces_existing_value(): void {
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'first', '' );
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'second', '' );
$result = WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 5, 'k', true );
$this->assertSame( array( 'second' ), $result );
// Ensure exactly one row (composite PK enforces this).
global $wpdb;
$count = (int) $wpdb->get_var(
'SELECT COUNT(*) FROM `' . self::TERM_MISC . "` WHERE term_id = 5 AND meta_key = 'k'"
);
$this->assertSame( 1, $count );
}
public function test_on_term_delete_removes_row(): void {
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'v', '' );
$this->assertSame( array( 'v' ), WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 5, 'k', true ) );
WPDO_Term_Comment_Misc_Bucket::on_term_delete( null, 5, 'k', '', false );
$this->assertNull( WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 5, 'k', true ) );
}
// ── Comment side ─────────────────────────────────────────────────────────
public function test_on_comment_add_writes_to_misc_table(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_comment_add( null, 8, 'note_group', 'baz', false );
$this->assertTrue( $result );
global $wpdb;
$value = $wpdb->get_var(
$wpdb->prepare(
'SELECT meta_value FROM `' . self::COMMENT_MISC . '` WHERE comment_id = %d AND meta_key = %s',
8,
'note_group'
)
);
$this->assertSame( 'baz', $value );
}
public function test_term_writes_do_not_pollute_comment_table(): void {
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'term_val', '' );
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENT_MISC . '`' );
$this->assertSame( 0, $count, 'Term writes must not appear in comment misc table' );
}
// ── Empty meta_key guard ────────────────────────────────────────────────
public function test_empty_meta_key_returns_check_unchanged(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_term_add( null, 5, '', 'value', false );
$this->assertNull( $result, 'Empty meta_key must not be written' );
}
public function test_non_string_meta_key_returns_check_unchanged(): void {
$result = WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 123, 'value', '' );
$this->assertNull( $result );
}
// ── is_enabled toggle ───────────────────────────────────────────────────
public function test_is_enabled_defaults_true(): void {
delete_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED );
$this->assertTrue( WPDO_Term_Comment_Misc_Bucket::is_enabled() );
}
public function test_is_enabled_respects_zero_value(): void {
update_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED, '0' );
$this->assertFalse( WPDO_Term_Comment_Misc_Bucket::is_enabled() );
delete_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED );
}
public function test_count_rows_returns_actual_count(): void {
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 1, 'a', 'x', '' );
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 2, 'b', 'y', '' );
WPDO_Term_Comment_Misc_Bucket::on_comment_update( null, 5, 'c', 'z', '' );
$this->assertSame( 2, WPDO_Term_Comment_Misc_Bucket::count_rows( 'term' ) );
$this->assertSame( 1, WPDO_Term_Comment_Misc_Bucket::count_rows( 'comment' ) );
}
}
@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Term_Comment_Shadow_Verifier (v2.12.5 Phase 5).
*
* Verifies the sample-and-compare contract:
* - sample_compare counts matched / diffs / missing_flat / missing_meta
* - throws on invalid entity_type / sample_size / empty keys
* - cron_tick gated by mode (no-op when neither term nor comment is shadow_read)
* - run_all aggregates per-group results
*/
class TermCommentShadowVerifierTest extends TestCase {
private const TERMS = 'wp_itest_terms';
private const TERMMETA = 'wp_itest_termmeta';
private const COMMENTS = 'wp_itest_comments';
private const COMMENTMETA = 'wp_itest_commentmeta';
private const TERM_FLAT = 'wp_itest_wpdo_term_hp_taxonomy';
private const COMMENT_FLAT = 'wp_itest_wpdo_comment_hp_review';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Term_Comment_Shadow_Verifier' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-term-comment-shadow-verifier.php';
}
$wpdb->terms = self::TERMS;
$wpdb->termmeta = self::TERMMETA;
$wpdb->comments = self::COMMENTS;
$wpdb->commentmeta = self::COMMENTMETA;
// Source tables (terms / comments) need term_id / comment_ID columns.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TERMS . '` (
term_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
name varchar(200) NOT NULL DEFAULT "",
PRIMARY KEY (term_id)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::COMMENTS . '` (
comment_ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
comment_post_ID bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (comment_ID)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::TERMMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
term_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY term_id (term_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4' );
$wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::COMMENTMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
comment_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY comment_id (comment_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_FLAT . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TERM_FLAT . '` (
term_id bigint(20) unsigned NOT NULL,
hp_sort_order int(11) DEFAULT NULL,
hp_default tinyint(1) DEFAULT NULL,
hp_icon varchar(64) DEFAULT NULL,
PRIMARY KEY (term_id)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENT_FLAT . '`' );
$wpdb->query(
'CREATE TABLE `' . self::COMMENT_FLAT . '` (
comment_id bigint(20) unsigned NOT NULL,
hp_rating tinyint(1) DEFAULT NULL,
PRIMARY KEY (comment_id)
) DEFAULT CHARACTER SET utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
foreach ( array( self::TERMS, self::COMMENTS, self::TERM_FLAT, self::COMMENT_FLAT ) as $tbl ) {
$wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' );
}
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TERMS . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTS . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::TERMMETA . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTMETA . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::TERM_FLAT . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::COMMENT_FLAT . '`' );
}
// ── sample_compare contract ─────────────────────────────────────────────
public function test_sample_compare_invalid_entity_type_throws(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'bogus',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' )
);
}
public function test_sample_compare_zero_sample_size_throws(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' ),
0
);
}
public function test_sample_compare_empty_keys_throws(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array()
);
}
public function test_sample_compare_returns_zeros_for_empty_db(): void {
$result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' )
);
$this->assertSame( 0, $result['sampled'] );
$this->assertSame( 'term', $result['entity_type'] );
$this->assertSame( 'hp_taxonomy', $result['group'] );
}
public function test_sample_compare_counts_matches_when_in_sync(): void {
global $wpdb;
// 3 terms, all in sync between wp_termmeta and flat table.
for ( $i = 1; $i <= 3; $i++ ) {
$wpdb->insert( self::TERMS, array( 'term_id' => $i, 'name' => 'term_' . $i ) );
$wpdb->insert( self::TERMMETA, array( 'term_id' => $i, 'meta_key' => 'hp_sort_order', 'meta_value' => $i * 10 ) );
$wpdb->insert( self::TERM_FLAT, array( 'term_id' => $i, 'hp_sort_order' => $i * 10 ) );
}
$result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' ),
10
);
$this->assertSame( 3, $result['sampled'] );
$this->assertSame( 3, $result['matched'] );
$this->assertSame( 0, $result['diffs'] );
$this->assertSame( 0, $result['missing_flat'] );
$this->assertSame( 0, $result['missing_meta'] );
}
public function test_sample_compare_detects_missing_flat(): void {
global $wpdb;
// 2 terms with wp_termmeta but no flat row.
$wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) );
$wpdb->insert( self::TERMS, array( 'term_id' => 2, 'name' => 'b' ) );
$wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => 5 ) );
$wpdb->insert( self::TERMMETA, array( 'term_id' => 2, 'meta_key' => 'hp_sort_order', 'meta_value' => 10 ) );
$result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' ),
10
);
$this->assertSame( 2, $result['sampled'] );
$this->assertSame( 0, $result['matched'] );
$this->assertSame( 2, $result['missing_flat'] );
}
public function test_sample_compare_detects_missing_meta(): void {
global $wpdb;
// 2 terms with flat rows but no wp_termmeta.
$wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) );
$wpdb->insert( self::TERMS, array( 'term_id' => 2, 'name' => 'b' ) );
$wpdb->insert( self::TERM_FLAT, array( 'term_id' => 1, 'hp_sort_order' => 5 ) );
$wpdb->insert( self::TERM_FLAT, array( 'term_id' => 2, 'hp_sort_order' => 10 ) );
$result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' ),
10
);
$this->assertSame( 2, $result['sampled'] );
$this->assertSame( 2, $result['missing_meta'] );
}
public function test_sample_compare_detects_value_diff(): void {
global $wpdb;
$wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) );
$wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ) );
$wpdb->insert( self::TERM_FLAT, array( 'term_id' => 1, 'hp_sort_order' => 99 ) );
$result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' ),
10
);
$this->assertSame( 1, $result['diffs'] );
$this->assertSame( 0, $result['matched'] );
}
public function test_sample_compare_loose_equal_matches_numeric(): void {
global $wpdb;
$wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) );
// wp_termmeta stores '5' as string, flat stores 5 as int — should match
$wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ) );
$wpdb->insert( self::TERM_FLAT, array( 'term_id' => 1, 'hp_sort_order' => 5 ) );
$result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'term',
'hp_taxonomy',
self::TERM_FLAT,
array( 'hp_sort_order' ),
10
);
$this->assertSame( 1, $result['matched'], 'String "5" must loose-equal int 5' );
$this->assertSame( 0, $result['diffs'] );
}
public function test_sample_compare_handles_comment_entity(): void {
global $wpdb;
$wpdb->insert( self::COMMENTS, array( 'comment_ID' => 1, 'comment_post_ID' => 100 ) );
$wpdb->insert( self::COMMENTMETA, array( 'comment_id' => 1, 'meta_key' => 'hp_rating', 'meta_value' => 5 ) );
$wpdb->insert( self::COMMENT_FLAT, array( 'comment_id' => 1, 'hp_rating' => 5 ) );
$result = WPDO_Term_Comment_Shadow_Verifier::sample_compare(
'comment',
'hp_review',
self::COMMENT_FLAT,
array( 'hp_rating' ),
10
);
$this->assertSame( 1, $result['sampled'] );
$this->assertSame( 1, $result['matched'] );
$this->assertSame( 'comment', $result['entity_type'] );
}
// ── cron_tick mode-gating ───────────────────────────────────────────────
public function test_cron_tick_no_op_when_neither_in_shadow_read(): void {
// Set both modes to dual_write so cron should no-op.
if ( class_exists( 'WPDO_Mode_Manager' ) ) {
WPDO_Mode_Manager::set( 'term', 'dual_write' );
WPDO_Mode_Manager::set( 'comment', 'dual_write' );
}
// cron_tick should return without error and not touch the tables.
WPDO_Term_Comment_Shadow_Verifier::cron_tick();
$this->assertTrue( true ); // No exception = no-op succeeded
}
}
+315
View File
@@ -0,0 +1,315 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Term_Stress_Tester (v2.13.0).
*
* Verifies the term stress tester contract:
* - State machine: start / get_state / get_progress / cancel
* - count_test_terms() matches slug prefix
* - cleanup() removes test terms + cascade
* - Input validation throws / errors correctly
* - Run benchmark structure
*
* Note: Realistic mode tests (wp_insert_term path) are exercised live in dev10
* smoke tests since the bootstrap wp_insert_term stub is intentionally minimal.
*/
class TermStressTesterTest extends TestCase {
private const TERMS = 'wp_itest_terms';
private const TERM_TAXONOMY = 'wp_itest_term_taxonomy';
private const TERMMETA = 'wp_itest_termmeta';
public static function setUpBeforeClass(): void {
global $wpdb;
if ( ! class_exists( 'WPDO_Term_Stress_Tester' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-term-stress-tester.php';
}
$wpdb->terms = self::TERMS;
$wpdb->termmeta = self::TERMMETA;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMS . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TERMS . '` (
term_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
name varchar(200) NOT NULL DEFAULT "",
slug varchar(200) NOT NULL DEFAULT "",
term_group bigint(10) NOT NULL DEFAULT 0,
PRIMARY KEY (term_id),
KEY slug (slug(191))
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_TAXONOMY . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TERM_TAXONOMY . '` (
term_taxonomy_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
term_id bigint(20) unsigned NOT NULL DEFAULT 0,
taxonomy varchar(32) NOT NULL DEFAULT "",
description longtext,
parent bigint(20) unsigned NOT NULL DEFAULT 0,
count bigint(20) NOT NULL DEFAULT 0,
PRIMARY KEY (term_taxonomy_id),
KEY taxonomy (taxonomy)
) DEFAULT CHARACTER SET utf8mb4'
);
$wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::TERMMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
term_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY term_id (term_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4' );
}
public static function tearDownAfterClass(): void {
global $wpdb;
foreach ( array( self::TERMS, self::TERM_TAXONOMY, self::TERMMETA ) as $tbl ) {
$wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' );
}
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TERMS . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::TERM_TAXONOMY . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::TERMMETA . '`' );
// Reset state per test so each starts idle.
unset( $GLOBALS['_wp_options'][ WPDO_Term_Stress_Tester::OPT_STATE ] );
unset( $GLOBALS['_wp_transients'][ WPDO_Term_Stress_Tester::CANCEL_FLAG ] );
unset( $GLOBALS['_wp_transients']['wpdo_term_stress_pump_lock'] );
}
// ── create() (fast-path direct SQL) ──────────────────────────────────────
public function test_create_inserts_terms_into_taxonomy(): void {
$result = WPDO_Term_Stress_Tester::create( 'category', 5 );
$this->assertSame( 5, $result['created'] );
$this->assertSame( 'category', $result['taxonomy'] );
$this->assertNotNull( $result['first_id'] );
$this->assertNotNull( $result['last_id'] );
global $wpdb;
$count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMS . '`' );
$this->assertSame( 5, $count );
$tax_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::TERM_TAXONOMY . "` WHERE taxonomy = 'category'" );
$this->assertSame( 5, $tax_count );
}
public function test_create_uses_stress_slug_prefix(): void {
WPDO_Term_Stress_Tester::create( 'category', 3 );
global $wpdb;
$prefix_count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `" . self::TERMS . "` WHERE slug LIKE %s",
WPDO_Term_Stress_Tester::TEST_TERM_PREFIX . '%'
)
);
$this->assertSame( 3, $prefix_count );
}
public function test_create_seeds_termmeta_keys(): void {
WPDO_Term_Stress_Tester::create( 'category', 3 );
global $wpdb;
$total_meta = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' );
// 3 terms × 3 keys (hp_sort_order/hp_default/hp_icon) = 9
$this->assertSame( 9, $total_meta );
}
public function test_create_rejects_empty_taxonomy(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Term_Stress_Tester::create( '', 3 );
}
public function test_create_rejects_zero_count(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Term_Stress_Tester::create( 'category', 0 );
}
public function test_create_rejects_excessive_count(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Term_Stress_Tester::create( 'category', 100001 );
}
// ── count_test_terms() ────────────────────────────────────────────────────
public function test_count_test_terms_returns_zero_for_empty(): void {
$this->assertSame( 0, WPDO_Term_Stress_Tester::count_test_terms() );
}
public function test_count_test_terms_counts_only_stress_prefix(): void {
WPDO_Term_Stress_Tester::create( 'category', 4 );
global $wpdb;
$wpdb->insert( self::TERMS, array( 'name' => 'Real', 'slug' => 'real-term', 'term_group' => 0 ) );
$this->assertSame( 4, WPDO_Term_Stress_Tester::count_test_terms() );
}
// ── cleanup() ─────────────────────────────────────────────────────────────
public function test_cleanup_removes_test_terms_and_cascade(): void {
WPDO_Term_Stress_Tester::create( 'category', 5 );
$this->assertSame( 5, WPDO_Term_Stress_Tester::count_test_terms() );
$result = WPDO_Term_Stress_Tester::cleanup();
$this->assertSame( 5, $result['deleted_terms'] );
global $wpdb;
$this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMS . '`' ) );
$this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' ) );
$this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERM_TAXONOMY . '`' ) );
}
public function test_cleanup_preserves_non_stress_terms(): void {
global $wpdb;
$wpdb->insert( self::TERMS, array( 'name' => 'Real', 'slug' => 'real-term', 'term_group' => 0 ) );
WPDO_Term_Stress_Tester::create( 'category', 3 );
$result = WPDO_Term_Stress_Tester::cleanup();
$this->assertSame( 3, $result['deleted_terms'] );
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMS . '`' );
$this->assertSame( 1, $remaining );
}
public function test_cleanup_idempotent_on_empty(): void {
$first = WPDO_Term_Stress_Tester::cleanup();
$second = WPDO_Term_Stress_Tester::cleanup();
$this->assertSame( 0, $first['deleted_terms'] );
$this->assertSame( 0, $second['deleted_terms'] );
}
// ── State machine ────────────────────────────────────────────────────────
public function test_get_state_returns_empty_when_idle(): void {
$this->assertSame( array(), WPDO_Term_Stress_Tester::get_state() );
}
public function test_get_progress_returns_idle_when_no_state(): void {
$progress = WPDO_Term_Stress_Tester::get_progress( false );
$this->assertSame( 'idle', $progress['status'] );
}
public function test_start_persists_state_with_running_status(): void {
// Stub taxonomy_exists() — fall back to true via global flag.
$GLOBALS['_taxonomy_exists_override'] = true;
$result = WPDO_Term_Stress_Tester::start( 'category', 10, 'fast', 5 );
$this->assertTrue( $result['ok'], 'start should succeed' );
$state = $result['state'];
$this->assertSame( 'running', $state['status'] );
$this->assertSame( 'category', $state['taxonomy'] );
$this->assertSame( 'fast', $state['mode'] );
$this->assertSame( 10, $state['target'] );
$this->assertSame( 5, $state['batch_size'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
public function test_start_rejects_unknown_taxonomy(): void {
$GLOBALS['_taxonomy_exists_override'] = false;
$result = WPDO_Term_Stress_Tester::start( 'never_exists', 10 );
$this->assertFalse( $result['ok'] );
$this->assertStringContainsString( 'unknown_taxonomy', $result['error'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
public function test_start_rejects_invalid_mode(): void {
$GLOBALS['_taxonomy_exists_override'] = true;
$result = WPDO_Term_Stress_Tester::start( 'category', 10, 'turbo' );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'invalid mode', $result['error'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
public function test_start_rejects_concurrent_run(): void {
$GLOBALS['_taxonomy_exists_override'] = true;
WPDO_Term_Stress_Tester::start( 'category', 10 );
$result = WPDO_Term_Stress_Tester::start( 'category', 5 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'already_running', $result['error'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
public function test_run_batch_advances_processed_count(): void {
$GLOBALS['_taxonomy_exists_override'] = true;
WPDO_Term_Stress_Tester::start( 'category', 6, 'fast', 3 );
WPDO_Term_Stress_Tester::run_batch();
$progress = WPDO_Term_Stress_Tester::get_progress( false );
$this->assertSame( 3, $progress['processed'] );
$this->assertSame( 1, $progress['batches_done'] );
$this->assertSame( 'running', $progress['status'] );
WPDO_Term_Stress_Tester::run_batch();
$progress = WPDO_Term_Stress_Tester::get_progress( false );
$this->assertSame( 6, $progress['processed'] );
$this->assertSame( 'completed', $progress['status'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
public function test_cancel_marks_state_as_cancelled(): void {
$GLOBALS['_taxonomy_exists_override'] = true;
WPDO_Term_Stress_Tester::start( 'category', 100, 'fast', 50 );
$result = WPDO_Term_Stress_Tester::cancel();
$this->assertTrue( $result['ok'] );
$this->assertSame( 'cancelled', $result['state']['status'] );
// In-flight batch run after cancel must NOT bump status back to running.
WPDO_Term_Stress_Tester::run_batch();
$state = WPDO_Term_Stress_Tester::get_state();
$this->assertSame( 'cancelled', $state['status'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
public function test_cancel_returns_no_active_job_when_idle(): void {
$result = WPDO_Term_Stress_Tester::cancel();
$this->assertTrue( $result['ok'] );
$this->assertSame( 'no_active_job', $result['message'] ?? '' );
}
public function test_get_progress_includes_pct_and_eta_keys(): void {
$GLOBALS['_taxonomy_exists_override'] = true;
WPDO_Term_Stress_Tester::start( 'category', 10, 'fast', 5 );
WPDO_Term_Stress_Tester::run_batch();
$progress = WPDO_Term_Stress_Tester::get_progress( false );
$this->assertArrayHasKey( 'pct', $progress );
$this->assertArrayHasKey( 'rate_per_sec', $progress );
$this->assertArrayHasKey( 'elapsed_sec', $progress );
$this->assertArrayHasKey( 'eta_sec', $progress );
$this->assertArrayHasKey( 'test_term_count', $progress );
$this->assertSame( 50.0, $progress['pct'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
public function test_run_benchmark_returns_structured_payload(): void {
$GLOBALS['_taxonomy_exists_override'] = true;
WPDO_Term_Stress_Tester::start( 'category', 4, 'fast', 4 );
WPDO_Term_Stress_Tester::run_batch();
$state = WPDO_Term_Stress_Tester::get_state();
$this->assertSame( 'completed', $state['status'] );
$this->assertIsArray( $state['benchmark'] );
$this->assertArrayHasKey( 'write', $state['benchmark'] );
$this->assertArrayHasKey( 'db_sizes', $state['benchmark'] );
$this->assertSame( 'category', $state['benchmark']['taxonomy'] );
unset( $GLOBALS['_taxonomy_exists_override'] );
}
}
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Termmeta_Cleaner — wp_termmeta garbage cleanup (v2.12.0).
*
* Verifies count_garbage() and delete_garbage() against a real MariaDB test table:
* - target=wxr_import → meta_key LIKE '_wxr_import_%'
* - target=demo_data → meta_key LIKE '_2meet_demo_%'
* - target=transients → meta_key LIKE '_transient_%' OR LIKE '_transient_timeout_%'
* - target=all → union of all three
*/
class TermmetaCleanerIntegrationTest extends TestCase {
private const TERMMETA = 'wp_itest_termmeta';
public static function setUpBeforeClass(): void {
global $wpdb;
require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-termmeta-cleaner.php';
// Override $wpdb->termmeta to point at our test table.
$wpdb->termmeta = self::TERMMETA;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMMETA . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TERMMETA . '` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
term_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY term_id (term_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMMETA . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TERMMETA . '`' );
}
private function seed( array $rows ): void {
global $wpdb;
foreach ( $rows as $row ) {
$wpdb->insert( self::TERMMETA, $row );
}
}
// ── count_garbage ────────────────────────────────────────────────────────
public function test_count_garbage_returns_zero_for_empty_table(): void {
$counts = WPDO_Termmeta_Cleaner::count_garbage( 'all' );
$this->assertSame( 0, $counts['wxr_import'] );
$this->assertSame( 0, $counts['demo_data'] );
$this->assertSame( 0, $counts['transients'] );
$this->assertSame( 0, $counts['total'] );
}
public function test_count_garbage_counts_wxr_import(): void {
$this->seed( array(
array( 'term_id' => 1, 'meta_key' => '_wxr_import_user_xyz', 'meta_value' => 'a' ),
array( 'term_id' => 2, 'meta_key' => '_wxr_import_post', 'meta_value' => 'b' ),
array( 'term_id' => 3, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ),
) );
$counts = WPDO_Termmeta_Cleaner::count_garbage( 'wxr_import' );
$this->assertSame( 2, $counts['wxr_import'] );
$this->assertSame( 0, $counts['demo_data'] );
$this->assertSame( 0, $counts['transients'] );
$this->assertSame( 2, $counts['total'] );
}
public function test_count_garbage_counts_demo_data(): void {
$this->seed( array(
array( 'term_id' => 1, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
array( 'term_id' => 2, 'meta_key' => '_2meet_demo_adv', 'meta_value' => '1' ),
array( 'term_id' => 3, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ),
) );
$counts = WPDO_Termmeta_Cleaner::count_garbage( 'demo_data' );
$this->assertSame( 0, $counts['wxr_import'] );
$this->assertSame( 2, $counts['demo_data'] );
$this->assertSame( 0, $counts['transients'] );
$this->assertSame( 2, $counts['total'] );
}
public function test_count_garbage_counts_transients(): void {
$this->seed( array(
array( 'term_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ),
array( 'term_id' => 1, 'meta_key' => '_transient_timeout_foo', 'meta_value' => '9999' ),
array( 'term_id' => 2, 'meta_key' => 'hp_default', 'meta_value' => '1' ),
) );
$counts = WPDO_Termmeta_Cleaner::count_garbage( 'transients' );
$this->assertSame( 2, $counts['transients'] );
$this->assertSame( 2, $counts['total'] );
}
public function test_count_garbage_all_unions_three_buckets(): void {
$this->seed( array(
array( 'term_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
array( 'term_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
array( 'term_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
array( 'term_id' => 4, 'meta_key' => 'hp_icon', 'meta_value' => 'star' ),
array( 'term_id' => 5, 'meta_key' => 'hp_default', 'meta_value' => '1' ),
) );
$counts = WPDO_Termmeta_Cleaner::count_garbage( 'all' );
$this->assertSame( 1, $counts['wxr_import'] );
$this->assertSame( 1, $counts['demo_data'] );
$this->assertSame( 1, $counts['transients'] );
$this->assertSame( 3, $counts['total'] );
}
// ── delete_garbage ────────────────────────────────────────────────────────
public function test_delete_garbage_removes_targeted_rows_only(): void {
$this->seed( array(
array( 'term_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
array( 'term_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
array( 'term_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
array( 'term_id' => 4, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ),
) );
$deleted = WPDO_Termmeta_Cleaner::delete_garbage( 'all' );
$this->assertSame( 1, $deleted['wxr_import'] );
$this->assertSame( 1, $deleted['demo_data'] );
$this->assertSame( 1, $deleted['transients'] );
$this->assertSame( 3, $deleted['total'] );
global $wpdb;
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' );
$this->assertSame( 1, $remaining, 'hp_sort_order must survive' );
}
public function test_delete_garbage_target_specific_only_removes_one_bucket(): void {
$this->seed( array(
array( 'term_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ),
array( 'term_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ),
array( 'term_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ),
) );
$deleted = WPDO_Termmeta_Cleaner::delete_garbage( 'wxr_import' );
$this->assertSame( 1, $deleted['wxr_import'] );
$this->assertSame( 0, $deleted['demo_data'] );
$this->assertSame( 0, $deleted['transients'] );
$this->assertSame( 1, $deleted['total'] );
global $wpdb;
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' );
$this->assertSame( 2, $remaining, '_2meet_demo + _transient must survive when target=wxr_import' );
}
public function test_delete_garbage_idempotent_on_clean_table(): void {
$this->seed( array(
array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ),
) );
$first = WPDO_Termmeta_Cleaner::delete_garbage( 'all' );
$second = WPDO_Termmeta_Cleaner::delete_garbage( 'all' );
$this->assertSame( 0, $first['total'] );
$this->assertSame( 0, $second['total'] );
global $wpdb;
$remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' );
$this->assertSame( 1, $remaining );
}
public function test_invalid_target_throws(): void {
$this->expectException( InvalidArgumentException::class );
WPDO_Termmeta_Cleaner::count_garbage( 'bogus' );
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_V2_Upgrader — atomic v1.3.x → v2.0.0 upgrade (PR-7).
*
* Covers the four upgrade scenarios from Part F.2:
* S1: clean install
* S2: v1.3.x → v2.0.0 (no UAE) ← dev10 production case
* S3: v1.3.x + UAE coexistence
* S4: HPCT residue (covered by existing wpdo_hpct_imported flag)
*
* @covers WPDO_V2_Upgrader
*/
class UpgradeV2Test extends TestCase {
public static function setUpBeforeClass(): void {
global $wpdb;
// Reset state for repeatability.
foreach ( array( 'audit', 'shadow_diffs', 'site_metrics', 'uni_options' ) as $t ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}wpdo_{$t}`" );
}
// Drop any leftover wp_uae_* probe tables from prior test runs.
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}uae_probe`" );
}
protected function setUp(): void {
// Clean options between tests for clean preconditions.
$GLOBALS['_wp_options'] = array();
}
// ── Pre-flight ──────────────────────────────────────────────────────────
public function test_pre_flight_passes_in_clean_environment(): void {
$checks = WPDO_V2_Upgrader::pre_flight_check();
$this->assertIsArray( $checks );
// PHP / WP / MySQL versions in this CI/dev environment must satisfy minimums.
$this->assertTrue( $checks['php_version'] );
$this->assertTrue( $checks['mysql_version'] );
}
public function test_pre_flight_detects_old_php(): void {
// Simulating an old PHP is impossible from PHP itself, so we only
// verify the keys are present + booleans.
$checks = WPDO_V2_Upgrader::pre_flight_check();
foreach ( array( 'php_version', 'wp_version', 'mysql_version', 'free_disk_mb', 'features_writable', 'no_active_migration' ) as $key ) {
$this->assertArrayHasKey( $key, $checks );
$this->assertIsBool( $checks[ $key ] );
}
}
// ── S1: clean install ─────────────────────────────────────────────────
public function test_s1_clean_install_creates_v2_schema(): void {
WPDO_V2_Upgrader::upgrade_to_v2();
$status = WPDO_Installer::v2_tables_status();
foreach ( $status as $exists ) {
$this->assertTrue( $exists );
}
}
// ── S2: v1.3.x → v2.0.0 (no UAE) ───────────────────────────────────────
public function test_s2_upgrade_marks_db_version(): void {
// Simulate v1.3.x state.
update_option( 'wpdo_db_version', '1.0.0' );
$ok = WPDO_V2_Upgrader::upgrade_to_v2();
$this->assertTrue( $ok );
$this->assertSame( '2.0.0', get_option( 'wpdo_db_version' ) );
$this->assertSame( 'complete', get_option( 'wpdo_v2_upgrade_status' ) );
}
public function test_s2_upgrade_seeds_entity_modules_in_features(): void {
update_option( 'wpdo_features', array( 'hot_hp_listing' => 'cutover' ) );
WPDO_V2_Upgrader::upgrade_to_v2();
$flags = get_option( 'wpdo_features' );
$this->assertSame( 'cutover', $flags['hot_hp_listing'], 'Pre-existing module state must be preserved' );
foreach ( array( 'entity_user', 'entity_term', 'entity_comment', 'entity_options' ) as $module ) {
$this->assertArrayHasKey( $module, $flags );
$this->assertSame( 'idle', $flags[ $module ] );
}
}
public function test_s2_upgrade_idempotent(): void {
WPDO_V2_Upgrader::upgrade_to_v2();
$ok = WPDO_V2_Upgrader::upgrade_to_v2(); // Second run must not fail.
$this->assertTrue( $ok );
}
// ── S3: UAE coexistence ───────────────────────────────────────────────
public function test_s3_detects_uae_data_when_table_present(): void {
global $wpdb;
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->prefix}uae_probe` ( id BIGINT PRIMARY KEY ) ENGINE=InnoDB" );
$this->assertTrue( WPDO_V2_Upgrader::detect_uae_data() );
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}uae_probe`" );
}
public function test_s3_no_uae_data_in_clean_environment(): void {
// dev10 case: no wp_uae_* tables.
$this->assertFalse( WPDO_V2_Upgrader::detect_uae_data() );
}
// ── Rollback ───────────────────────────────────────────────────────────
public function test_rollback_restores_features_backup(): void {
$original = array( 'hot_hp_listing' => 'cutover' );
update_option( 'wpdo_features', $original );
WPDO_V2_Upgrader::upgrade_to_v2();
// Simulate user wants to roll back.
WPDO_V2_Upgrader::rollback_v2( true );
$this->assertSame( $original, get_option( 'wpdo_features' ) );
$this->assertSame( '1.0.0', get_option( 'wpdo_db_version' ) );
$this->assertSame( 'rolled_back', get_option( 'wpdo_v2_upgrade_status' ) );
}
public function test_rollback_with_keep_data_preserves_v2_tables(): void {
WPDO_V2_Upgrader::upgrade_to_v2();
WPDO_V2_Upgrader::rollback_v2( true );
$status = WPDO_Installer::v2_tables_status();
foreach ( $status as $table => $exists ) {
$this->assertTrue( $exists, "{$table} must remain after rollback with --keep-data" );
}
}
}
@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
/**
* Integration tests — Warm Zone (Zone B) + Archive Zone (Zone D) lifecycle.
*
* Covers:
* - Warm zone: set/get/TTL/purge_expired/flush_views
* - Archive zone: archive_batch/get/stats/restore/gzip roundtrip
* - WPDO_Listing_Stats: increment + flush + REST view count
*
* Uses dedicated test tables (wp_itest_ prefix) to avoid collisions.
*/
use PHPUnit\Framework\TestCase;
class WarmArchiveIntegrationTest extends TestCase {
private static string $warm_table;
private static string $archive_table;
private static string $errors_table;
private static string $postmeta_table;
public static function setUpBeforeClass(): void {
global $wpdb;
self::$warm_table = $wpdb->prefix . 'wpdo_warm';
self::$archive_table = $wpdb->prefix . 'wpdo_archive';
self::$errors_table = $wpdb->prefix . 'wpdo_errors';
self::$postmeta_table = $wpdb->postmeta;
// DROP + CREATE ensures clean schema even after interrupted prior runs.
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
$wpdb->query(
"CREATE TABLE `" . self::$warm_table . "` (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
meta_key VARCHAR(255) NOT NULL,
meta_value LONGTEXT,
expires_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY post_meta (post_id, meta_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Create archive table (matches WPDO_Installer::install_system_tables DDL).
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$archive_table . "`" );
$wpdb->query(
"CREATE TABLE `" . self::$archive_table . "` (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
post_type VARCHAR(20) NOT NULL DEFAULT '',
meta_key VARCHAR(255) NOT NULL DEFAULT '',
meta_value LONGTEXT,
compressed TINYINT(1) NOT NULL DEFAULT 0,
archived_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
original_meta_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_post_id (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Create errors table (needed by WPDO_Logger).
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$errors_table . "`" );
$wpdb->query(
"CREATE TABLE `" . self::$errors_table . "` (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
severity VARCHAR(20) NOT NULL DEFAULT 'error',
module VARCHAR(100) NOT NULL DEFAULT '',
zone VARCHAR(50) DEFAULT NULL,
hook VARCHAR(100) NOT NULL DEFAULT '',
message TEXT NOT NULL,
context LONGTEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Create postmeta table (needed by flush_views_to_postmeta batch query).
// Only create if it does not already exist — shared with other test classes.
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `" . self::$postmeta_table . "` (
meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
meta_key VARCHAR(255) DEFAULT NULL,
meta_value LONGTEXT,
PRIMARY KEY (meta_id),
KEY post_id (post_id),
KEY meta_key (meta_key(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$archive_table . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$errors_table . "`" );
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$postmeta_table . "`" );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( "TRUNCATE TABLE `" . self::$warm_table . "`" );
$wpdb->query( "TRUNCATE TABLE `" . self::$archive_table . "`" );
$GLOBALS['_wp_cache'] = [];
$GLOBALS['_wp_postmeta'] = [];
$GLOBALS['_wp_options'] = [];
}
// ── Zone B (Warm) ─────────────────────────────────────────────────────────
public function test_warm_set_and_get(): void {
WPDO_Zone_Warm::set( 100, 'wp_key', 'hello', null );
$val = WPDO_Zone_Warm::get( 100, 'wp_key' );
$this->assertSame( 'hello', $val );
}
public function test_warm_expired_returns_null(): void {
global $wpdb;
// Insert already-expired entry.
$wpdb->query(
"INSERT INTO `" . self::$warm_table . "` (post_id, meta_key, meta_value, expires_at)
VALUES (101, 'stale_key', 'old', '2000-01-01 00:00:00')"
);
$val = WPDO_Zone_Warm::get( 101, 'stale_key' );
$this->assertNull( $val );
}
public function test_warm_purge_expired(): void {
global $wpdb;
$wpdb->query(
"INSERT INTO `" . self::$warm_table . "` (post_id, meta_key, meta_value, expires_at)
VALUES (102, 'k1', 'v1', '2000-01-01 00:00:00'),
(103, 'k2', 'v2', DATE_ADD(NOW(), INTERVAL 1 HOUR))"
);
$deleted = WPDO_Zone_Warm::purge_expired();
$this->assertGreaterThanOrEqual( 1, $deleted );
// k2 (future TTL) should still exist.
$this->assertNotNull( WPDO_Zone_Warm::get( 103, 'k2' ) );
}
public function test_warm_delete_all_for_post(): void {
WPDO_Zone_Warm::set( 200, 'a', 'va', null );
WPDO_Zone_Warm::set( 200, 'b', 'vb', null );
WPDO_Zone_Warm::set( 201, 'a', 'other', null );
WPDO_Zone_Warm::delete_all( 200 );
$this->assertNull( WPDO_Zone_Warm::get( 200, 'a' ) );
$this->assertNull( WPDO_Zone_Warm::get( 200, 'b' ) );
$this->assertSame( 'other', WPDO_Zone_Warm::get( 201, 'a' ) );
}
// ── Zone D (Archive) ──────────────────────────────────────────────────────
/** Helper: build a row for archive_batch with required post_type. */
private function archive_rows( int $post_id, array $metas ): array {
return array_map( fn( $m ) => array_merge(
[ 'post_id' => $post_id, 'post_type' => 'hp_listing', 'meta_id' => 0 ],
$m
), $metas );
}
public function test_archive_batch_stores_compressed(): void {
global $wpdb;
$rows = $this->archive_rows( 400, [
[ 'meta_key' => 'hp_price', 'meta_value' => '999' ],
[ 'meta_key' => 'hp_featured', 'meta_value' => '1' ],
] );
WPDO_Zone_Archive::archive_batch( $rows, true );
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$archive_table . "` WHERE post_id = 400" );
$this->assertSame( 2, $count );
$comp = (int) $wpdb->get_var( "SELECT SUM(compressed) FROM `" . self::$archive_table . "` WHERE post_id = 400" );
$this->assertSame( 2, $comp );
}
public function test_archive_get_returns_values(): void {
$rows = $this->archive_rows( 401, [
[ 'meta_key' => 'hp_price', 'meta_value' => '500' ],
[ 'meta_key' => 'hp_verified', 'meta_value' => '1' ],
] );
WPDO_Zone_Archive::archive_batch( $rows, false );
$result = WPDO_Zone_Archive::get( 401 );
$this->assertCount( 2, $result );
$by_key = array_column( $result, 'meta_value', 'meta_key' );
$this->assertSame( '500', $by_key['hp_price'] );
$this->assertSame( '1', $by_key['hp_verified'] );
}
public function test_archive_get_with_key_filter(): void {
$rows = $this->archive_rows( 402, [
[ 'meta_key' => 'hp_price', 'meta_value' => '250' ],
[ 'meta_key' => 'hp_featured', 'meta_value' => '0' ],
] );
WPDO_Zone_Archive::archive_batch( $rows, false );
$result = WPDO_Zone_Archive::get( 402, 'hp_price' );
$this->assertCount( 1, $result );
$this->assertSame( 'hp_price', $result[0]['meta_key'] );
}
public function test_archive_restore_writes_postmeta(): void {
global $wpdb;
$rows = $this->archive_rows( 403, [
[ 'meta_key' => 'hp_price', 'meta_value' => '777' ],
] );
WPDO_Zone_Archive::archive_batch( $rows, false );
$restored = WPDO_Zone_Archive::restore( 403 );
$this->assertSame( 1, $restored );
$pm = $GLOBALS['_wp_postmeta'][403]['hp_price'] ?? null;
$this->assertSame( '777', $pm );
$remaining = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$archive_table . "` WHERE post_id = 403" );
$this->assertSame( 0, $remaining );
}
public function test_archive_gzip_roundtrip(): void {
$rows = $this->archive_rows( 404, [
[ 'meta_key' => 'hp_description', 'meta_value' => str_repeat( 'Lorem ipsum ', 50 ) ],
] );
WPDO_Zone_Archive::archive_batch( $rows, true );
$result = WPDO_Zone_Archive::get( 404 );
$this->assertCount( 1, $result );
$this->assertStringContainsString( 'Lorem ipsum', $result[0]['meta_value'] );
}
public function test_archive_delete_removes_rows(): void {
global $wpdb;
$rows = $this->archive_rows( 405, [
[ 'meta_key' => 'hp_price', 'meta_value' => '1' ],
] );
WPDO_Zone_Archive::archive_batch( $rows, false );
WPDO_Zone_Archive::delete( 405 );
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$archive_table . "` WHERE post_id = 405" );
$this->assertSame( 0, $count );
}
public function test_archive_stats_counts_correctly(): void {
global $wpdb;
$wpdb->query( "TRUNCATE TABLE `" . self::$archive_table . "`" );
WPDO_Zone_Archive::archive_batch( $this->archive_rows( 500, [
[ 'meta_key' => 'k1', 'meta_value' => 'a' ],
[ 'meta_key' => 'k2', 'meta_value' => 'b' ],
] ), true );
WPDO_Zone_Archive::archive_batch( $this->archive_rows( 502, [
[ 'meta_key' => 'k3', 'meta_value' => 'c' ],
] ), false );
$stats = WPDO_Zone_Archive::stats();
$this->assertSame( 3, $stats['total_rows'] );
$this->assertSame( 2, $stats['compressed_rows'] );
}
}
@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Zone_Archive against real MariaDB.
*
* Covers archive/get (with gzip decompression), archive_batch (transaction),
* delete, stats(), and restore().
*/
class ZoneArchiveIntegrationTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_archive';
// ── Fixture lifecycle ─────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
$wpdb->query(
'CREATE TABLE IF NOT EXISTS `' . self::TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`post_type` varchar(20) NOT NULL DEFAULT \'\',
`meta_key` varchar(255) NOT NULL DEFAULT \'\',
`meta_value` longtext DEFAULT NULL,
`compressed` tinyint(1) NOT NULL DEFAULT 0,
`archived_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
`original_meta_id` bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `post_id` (`post_id`),
KEY `archived_at` (`archived_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
$GLOBALS['_wp_postmeta'] = [];
}
// ── archive / get ─────────────────────────────────────────────────────
public function test_archive_stores_plain_entry(): void {
WPDO_Zone_Archive::archive( 1, 'hp_listing', 'hp_price', '199.99' );
$rows = WPDO_Zone_Archive::get( 1 );
$this->assertCount( 1, $rows );
$this->assertSame( 'hp_price', $rows[0]['meta_key'] );
$this->assertSame( '199.99', $rows[0]['meta_value'] );
}
public function test_archive_with_compression_stores_and_decompresses(): void {
$original = 'large content that compresses well: ' . str_repeat( 'abcdef', 50 );
WPDO_Zone_Archive::archive( 2, 'hp_listing', 'hp_desc', $original, 0, true );
$rows = WPDO_Zone_Archive::get( 2, 'hp_desc' );
$this->assertCount( 1, $rows );
// get() decompresses automatically — value must match original.
$this->assertSame( $original, $rows[0]['meta_value'] );
}
public function test_get_with_meta_key_filter_returns_only_that_key(): void {
WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_price', '50.00' );
WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_featured', '1' );
WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_verified', '1' );
$rows = WPDO_Zone_Archive::get( 3, 'hp_price' );
$this->assertCount( 1, $rows );
$this->assertSame( 'hp_price', $rows[0]['meta_key'] );
}
public function test_get_without_filter_returns_all_keys(): void {
WPDO_Zone_Archive::archive( 4, 'hp_listing', 'hp_price', '75.00' );
WPDO_Zone_Archive::archive( 4, 'hp_listing', 'hp_featured', '0' );
$rows = WPDO_Zone_Archive::get( 4 );
$this->assertCount( 2, $rows );
}
public function test_get_returns_empty_for_missing_post(): void {
$rows = WPDO_Zone_Archive::get( 9999 );
$this->assertSame( [], $rows );
}
// ── archive_batch ────────────────────────────────────────────────────
public function test_archive_batch_stores_all_entries_in_transaction(): void {
$entries = [
[ 'post_id' => 5, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '100.00', 'meta_id' => 0 ],
[ 'post_id' => 5, 'post_type' => 'hp_listing', 'meta_key' => 'hp_featured', 'meta_value' => '1', 'meta_id' => 0 ],
[ 'post_id' => 6, 'post_type' => 'hp_vendor', 'meta_key' => 'hp_rate', 'meta_value' => '50.00', 'meta_id' => 0 ],
];
WPDO_Zone_Archive::archive_batch( $entries );
$this->assertCount( 2, WPDO_Zone_Archive::get( 5 ) );
$this->assertCount( 1, WPDO_Zone_Archive::get( 6 ) );
}
public function test_archive_batch_with_compression(): void {
$original = str_repeat( 'x', 200 );
WPDO_Zone_Archive::archive_batch(
[ [ 'post_id' => 7, 'post_type' => 'hp_listing', 'meta_key' => 'hp_desc', 'meta_value' => $original, 'meta_id' => 0 ] ],
true
);
$rows = WPDO_Zone_Archive::get( 7, 'hp_desc' );
$this->assertSame( $original, $rows[0]['meta_value'] );
}
// ── delete ───────────────────────────────────────────────────────────
public function test_delete_removes_all_entries_for_post(): void {
WPDO_Zone_Archive::archive( 8, 'hp_listing', 'hp_price', '30.00' );
WPDO_Zone_Archive::archive( 8, 'hp_listing', 'hp_featured', '1' );
$this->assertCount( 2, WPDO_Zone_Archive::get( 8 ) );
WPDO_Zone_Archive::delete( 8 );
$this->assertSame( [], WPDO_Zone_Archive::get( 8 ) );
}
public function test_delete_does_not_affect_other_posts(): void {
WPDO_Zone_Archive::archive( 9, 'hp_listing', 'hp_price', '10.00' );
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '20.00' );
WPDO_Zone_Archive::delete( 9 );
$this->assertSame( [], WPDO_Zone_Archive::get( 9 ) );
$this->assertCount( 1, WPDO_Zone_Archive::get( 10 ) );
}
// ── stats ─────────────────────────────────────────────────────────────
public function test_stats_counts_total_and_compressed_rows(): void {
WPDO_Zone_Archive::archive( 11, 'hp_listing', 'hp_price', '1.00', 0, false );
WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '2.00', 0, true );
WPDO_Zone_Archive::archive( 13, 'hp_listing', 'hp_price', '3.00', 0, true );
$stats = WPDO_Zone_Archive::stats();
$this->assertSame( 3, $stats['total_rows'] );
$this->assertSame( 2, $stats['compressed_rows'] );
}
public function test_stats_groups_by_post_type(): void {
WPDO_Zone_Archive::archive( 14, 'hp_listing', 'hp_price', '1.00' );
WPDO_Zone_Archive::archive( 15, 'hp_listing', 'hp_price', '2.00' );
WPDO_Zone_Archive::archive( 16, 'hp_vendor', 'hp_rate', '3.00' );
$stats = WPDO_Zone_Archive::stats();
$type_map = array_column( $stats['post_types'], 'cnt', 'post_type' );
$this->assertSame( '2', $type_map['hp_listing'] );
$this->assertSame( '1', $type_map['hp_vendor'] );
}
public function test_stats_returns_zeros_on_empty_table(): void {
$stats = WPDO_Zone_Archive::stats();
$this->assertSame( 0, $stats['total_rows'] );
$this->assertSame( 0, $stats['compressed_rows'] );
$this->assertSame( [], $stats['post_types'] );
}
// ── restore ───────────────────────────────────────────────────────────
public function test_restore_writes_to_postmeta_and_removes_from_archive(): void {
WPDO_Zone_Archive::archive( 17, 'hp_listing', 'hp_price', '99.00' );
WPDO_Zone_Archive::archive( 17, 'hp_listing', 'hp_featured', '1' );
$count = WPDO_Zone_Archive::restore( 17 );
// Two entries restored.
$this->assertSame( 2, $count );
// Postmeta updated via stub.
$this->assertSame( '99.00', $GLOBALS['_wp_postmeta'][17]['hp_price'] );
$this->assertSame( '1', $GLOBALS['_wp_postmeta'][17]['hp_featured'] );
// Archive cleared.
$this->assertSame( [], WPDO_Zone_Archive::get( 17 ) );
}
public function test_restore_with_meta_key_filter_only_restores_that_key(): void {
WPDO_Zone_Archive::archive( 18, 'hp_listing', 'hp_price', '55.00' );
WPDO_Zone_Archive::archive( 18, 'hp_listing', 'hp_featured', '0' );
$count = WPDO_Zone_Archive::restore( 18, 'hp_price' );
$this->assertSame( 1, $count );
$this->assertSame( '55.00', $GLOBALS['_wp_postmeta'][18]['hp_price'] );
// hp_featured should still be in archive.
$remaining = WPDO_Zone_Archive::get( 18 );
$this->assertCount( 1, $remaining );
$this->assertSame( 'hp_featured', $remaining[0]['meta_key'] );
}
}
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Zone_Cold against real MariaDB.
*
* Creates a dedicated cold table (wp_itest_wpdo_cold_itest) to exercise all
* Zone C operations: set / get, set_many / get_blob, remove, delete, and
* Object Cache invalidation.
*
* post_type = 'itest' maps to table prefix wp_itest_wpdo_cold_itest.
*/
class ZoneColdIntegrationTest extends TestCase {
private const POST_TYPE = 'itest';
/** Derived at runtime: $wpdb->prefix . 'wpdo_cold_itest' */
private static string $table;
// ── Fixture lifecycle ─────────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
self::$table = $wpdb->prefix . 'wpdo_cold_' . self::POST_TYPE;
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `" . self::$table . "` (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
data LONGTEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
UNIQUE KEY ui_post_id (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$table . "`" );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( "TRUNCATE TABLE `" . self::$table . "`" );
$GLOBALS['_wp_cache'] = [];
}
// ── set / get ─────────────────────────────────────────────────────────────
public function test_set_and_get_single_field(): void {
WPDO_Zone_Cold::set( 100, self::POST_TYPE, 'hp_description', 'Hello World' );
$val = WPDO_Zone_Cold::get( 100, self::POST_TYPE, 'hp_description' );
$this->assertSame( 'Hello World', $val );
}
public function test_get_returns_null_for_missing_key(): void {
WPDO_Zone_Cold::set( 101, self::POST_TYPE, 'hp_description', 'present' );
$val = WPDO_Zone_Cold::get( 101, self::POST_TYPE, 'missing_key' );
$this->assertNull( $val );
}
public function test_get_returns_null_for_missing_post(): void {
$val = WPDO_Zone_Cold::get( 9999, self::POST_TYPE, 'hp_description' );
$this->assertNull( $val );
}
public function test_set_overwrites_existing_value(): void {
WPDO_Zone_Cold::set( 102, self::POST_TYPE, 'hp_website', 'http://old.example.com' );
WPDO_Zone_Cold::set( 102, self::POST_TYPE, 'hp_website', 'http://new.example.com' );
$val = WPDO_Zone_Cold::get( 102, self::POST_TYPE, 'hp_website' );
$this->assertSame( 'http://new.example.com', $val );
}
public function test_set_preserves_other_keys_in_blob(): void {
WPDO_Zone_Cold::set( 103, self::POST_TYPE, 'hp_description', 'Keep me' );
WPDO_Zone_Cold::set( 103, self::POST_TYPE, 'hp_website', 'https://keep.example.com' );
// Update only one key.
WPDO_Zone_Cold::set( 103, self::POST_TYPE, 'hp_website', 'https://updated.example.com' );
$this->assertSame( 'Keep me', WPDO_Zone_Cold::get( 103, self::POST_TYPE, 'hp_description' ) );
$this->assertSame( 'https://updated.example.com', WPDO_Zone_Cold::get( 103, self::POST_TYPE, 'hp_website' ) );
}
// ── set_many / get_blob ───────────────────────────────────────────────────
public function test_set_many_stores_multiple_fields(): void {
WPDO_Zone_Cold::set_many( 200, self::POST_TYPE, [
'hp_description' => 'A great listing',
'hp_website' => 'https://example.com',
'hp_facebook' => 'https://facebook.com/test',
] );
$this->assertSame( 'A great listing', WPDO_Zone_Cold::get( 200, self::POST_TYPE, 'hp_description' ) );
$this->assertSame( 'https://example.com', WPDO_Zone_Cold::get( 200, self::POST_TYPE, 'hp_website' ) );
$this->assertSame( 'https://facebook.com/test', WPDO_Zone_Cold::get( 200, self::POST_TYPE, 'hp_facebook' ) );
}
public function test_get_blob_returns_all_fields(): void {
WPDO_Zone_Cold::set_many( 201, self::POST_TYPE, [
'hp_description' => 'Blob test',
'hp_website' => 'https://blob.example.com',
] );
$blob = WPDO_Zone_Cold::get_blob( 201, self::POST_TYPE );
$this->assertIsArray( $blob );
$this->assertArrayHasKey( 'hp_description', $blob );
$this->assertArrayHasKey( 'hp_website', $blob );
$this->assertSame( 'Blob test', $blob['hp_description'] );
$this->assertSame( 'https://blob.example.com', $blob['hp_website'] );
}
public function test_get_blob_returns_empty_array_for_missing_post(): void {
$blob = WPDO_Zone_Cold::get_blob( 9998, self::POST_TYPE );
$this->assertIsArray( $blob );
$this->assertEmpty( $blob );
}
public function test_set_many_merges_with_existing_blob(): void {
WPDO_Zone_Cold::set_many( 202, self::POST_TYPE, [ 'hp_description' => 'First' ] );
WPDO_Zone_Cold::set_many( 202, self::POST_TYPE, [ 'hp_website' => 'https://merge.example.com' ] );
$this->assertSame( 'First', WPDO_Zone_Cold::get( 202, self::POST_TYPE, 'hp_description' ) );
$this->assertSame( 'https://merge.example.com', WPDO_Zone_Cold::get( 202, self::POST_TYPE, 'hp_website' ) );
}
// ── remove ────────────────────────────────────────────────────────────────
public function test_remove_key_from_blob(): void {
WPDO_Zone_Cold::set_many( 300, self::POST_TYPE, [
'hp_description' => 'Keep me',
'hp_website' => 'https://remove.example.com',
] );
WPDO_Zone_Cold::remove( 300, self::POST_TYPE, 'hp_website' );
$this->assertSame( 'Keep me', WPDO_Zone_Cold::get( 300, self::POST_TYPE, 'hp_description' ) );
$this->assertNull( WPDO_Zone_Cold::get( 300, self::POST_TYPE, 'hp_website' ) );
}
public function test_remove_nonexistent_key_does_not_error(): void {
WPDO_Zone_Cold::set( 301, self::POST_TYPE, 'hp_description', 'Safe' );
WPDO_Zone_Cold::remove( 301, self::POST_TYPE, 'no_such_key' );
// Original key should still be intact.
$this->assertSame( 'Safe', WPDO_Zone_Cold::get( 301, self::POST_TYPE, 'hp_description' ) );
}
// ── delete ────────────────────────────────────────────────────────────────
public function test_delete_removes_row(): void {
global $wpdb;
WPDO_Zone_Cold::set( 400, self::POST_TYPE, 'hp_description', 'To be deleted' );
WPDO_Zone_Cold::delete( 400, self::POST_TYPE );
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$table . "` WHERE post_id = 400" );
$this->assertSame( 0, $count );
$this->assertNull( WPDO_Zone_Cold::get( 400, self::POST_TYPE, 'hp_description' ) );
}
public function test_delete_nonexistent_post_does_not_error(): void {
WPDO_Zone_Cold::delete( 9997, self::POST_TYPE );
$this->assertTrue( true ); // Must not throw.
}
// ── Object Cache ──────────────────────────────────────────────────────────
public function test_get_blob_populates_object_cache(): void {
WPDO_Zone_Cold::set( 500, self::POST_TYPE, 'hp_description', 'Cached value' );
// Clear cache to force a DB read on the next call.
$GLOBALS['_wp_cache'] = [];
// First get: reads from DB, warms the cache.
$val = WPDO_Zone_Cold::get( 500, self::POST_TYPE, 'hp_description' );
$this->assertSame( 'Cached value', $val );
// Cache entry must now exist.
$cached = wp_cache_get( 'cold_500', 'wpdo_cold_itest' );
$this->assertIsArray( $cached );
$this->assertSame( 'Cached value', $cached['hp_description'] );
}
public function test_set_invalidates_object_cache(): void {
WPDO_Zone_Cold::set( 501, self::POST_TYPE, 'hp_description', 'Original' );
// Warm the cache by reading once.
WPDO_Zone_Cold::get( 501, self::POST_TYPE, 'hp_description' );
$this->assertNotFalse( wp_cache_get( 'cold_501', 'wpdo_cold_itest' ) );
// Write a new value — must invalidate the cached blob.
WPDO_Zone_Cold::set( 501, self::POST_TYPE, 'hp_description', 'Updated' );
$this->assertFalse( wp_cache_get( 'cold_501', 'wpdo_cold_itest' ) );
// Subsequent read must return the updated value (from DB).
$val = WPDO_Zone_Cold::get( 501, self::POST_TYPE, 'hp_description' );
$this->assertSame( 'Updated', $val );
}
public function test_delete_invalidates_object_cache(): void {
WPDO_Zone_Cold::set( 502, self::POST_TYPE, 'hp_description', 'Will be deleted' );
// Warm the cache.
WPDO_Zone_Cold::get( 502, self::POST_TYPE, 'hp_description' );
// Delete — must clear cache.
WPDO_Zone_Cold::delete( 502, self::POST_TYPE );
$this->assertFalse( wp_cache_get( 'cold_502', 'wpdo_cold_itest' ) );
}
// ── isolation ─────────────────────────────────────────────────────────────
public function test_different_post_ids_are_independent(): void {
WPDO_Zone_Cold::set( 600, self::POST_TYPE, 'hp_description', 'Post 600' );
WPDO_Zone_Cold::set( 601, self::POST_TYPE, 'hp_description', 'Post 601' );
$this->assertSame( 'Post 600', WPDO_Zone_Cold::get( 600, self::POST_TYPE, 'hp_description' ) );
$this->assertSame( 'Post 601', WPDO_Zone_Cold::get( 601, self::POST_TYPE, 'hp_description' ) );
}
}
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Zone_Hot against real MariaDB.
*
* Creates a dedicated test table (wp_itest_wpdo_hot_hp_listing) in
* setUpBeforeClass() and drops it in tearDownAfterClass(), so the real
* DB is never polluted with test data.
*/
class ZoneHotIntegrationTest extends TestCase {
private const POST_TYPE = 'hp_listing';
private const TABLE = 'wp_itest_wpdo_hot_hp_listing';
// ── Fixture lifecycle ─────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
// DROP + CREATE ensures clean schema even after interrupted prior runs.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`hp_price` decimal(10,2) NOT NULL DEFAULT 0,
`hp_featured` tinyint(1) NOT NULL DEFAULT 0,
`updated_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
PRIMARY KEY (`id`),
UNIQUE KEY `post_id` (`post_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
}
// ── set / get ────────────────────────────────────────────────────────
public function test_set_and_get_single_field(): void {
WPDO_Zone_Hot::set( 1, self::POST_TYPE, 'hp_price', '199.99' );
$val = WPDO_Zone_Hot::get( 1, self::POST_TYPE, 'hp_price' );
$this->assertSame( '199.99', $val );
}
public function test_get_returns_null_for_missing_post(): void {
$val = WPDO_Zone_Hot::get( 9999, self::POST_TYPE, 'hp_price' );
$this->assertNull( $val );
}
public function test_set_overwrites_existing_value(): void {
WPDO_Zone_Hot::set( 2, self::POST_TYPE, 'hp_price', '50.00' );
WPDO_Zone_Hot::set( 2, self::POST_TYPE, 'hp_price', '75.00' );
$val = WPDO_Zone_Hot::get( 2, self::POST_TYPE, 'hp_price' );
$this->assertSame( '75.00', $val );
}
public function test_set_featured_integer_field(): void {
WPDO_Zone_Hot::set( 3, self::POST_TYPE, 'hp_featured', '1' );
$val = WPDO_Zone_Hot::get( 3, self::POST_TYPE, 'hp_featured' );
$this->assertSame( '1', $val );
}
// ── set_many / get_row ───────────────────────────────────────────────
public function test_set_many_stores_multiple_columns(): void {
WPDO_Zone_Hot::set_many( 4, self::POST_TYPE, [
'hp_price' => '299.00',
'hp_featured' => '1',
] );
$row = WPDO_Zone_Hot::get_row( 4, self::POST_TYPE );
$this->assertIsArray( $row );
$this->assertSame( '299.00', $row['hp_price'] );
$this->assertSame( '1', $row['hp_featured'] );
}
public function test_set_many_overwrites_on_second_call(): void {
WPDO_Zone_Hot::set_many( 5, self::POST_TYPE, [ 'hp_price' => '100.00', 'hp_featured' => '0' ] );
WPDO_Zone_Hot::set_many( 5, self::POST_TYPE, [ 'hp_price' => '200.00', 'hp_featured' => '1' ] );
$row = WPDO_Zone_Hot::get_row( 5, self::POST_TYPE );
$this->assertSame( '200.00', $row['hp_price'] );
$this->assertSame( '1', $row['hp_featured'] );
}
public function test_get_row_returns_null_for_missing_post(): void {
$row = WPDO_Zone_Hot::get_row( 9998, self::POST_TYPE );
$this->assertNull( $row );
}
// ── delete ───────────────────────────────────────────────────────────
public function test_delete_removes_row(): void {
WPDO_Zone_Hot::set( 6, self::POST_TYPE, 'hp_price', '42.00' );
$this->assertNotNull( WPDO_Zone_Hot::get( 6, self::POST_TYPE, 'hp_price' ) );
WPDO_Zone_Hot::delete( 6, self::POST_TYPE );
$this->assertNull( WPDO_Zone_Hot::get( 6, self::POST_TYPE, 'hp_price' ) );
}
public function test_delete_nonexistent_post_does_not_error(): void {
// Should complete without throwing.
WPDO_Zone_Hot::delete( 9997, self::POST_TYPE );
$this->assertTrue( true );
}
// ── isolation ────────────────────────────────────────────────────────
public function test_different_post_ids_are_independent(): void {
WPDO_Zone_Hot::set( 10, self::POST_TYPE, 'hp_price', '10.00' );
WPDO_Zone_Hot::set( 11, self::POST_TYPE, 'hp_price', '11.00' );
$this->assertSame( '10.00', WPDO_Zone_Hot::get( 10, self::POST_TYPE, 'hp_price' ) );
$this->assertSame( '11.00', WPDO_Zone_Hot::get( 11, self::POST_TYPE, 'hp_price' ) );
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Zone_Warm against real MariaDB.
*
* Creates a dedicated test table (wp_itest_wpdo_warm) so the real
* production warm table is never touched.
*/
class ZoneWarmIntegrationTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_warm';
// ── Fixture lifecycle ─────────────────────────────────────────────────
public static function setUpBeforeClass(): void {
global $wpdb;
// DROP + CREATE ensures clean schema even after interrupted prior runs.
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query(
'CREATE TABLE `' . self::TABLE . '` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`meta_key` varchar(255) NOT NULL DEFAULT \'\',
`meta_value` longtext DEFAULT NULL,
`expires_at` datetime DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\',
PRIMARY KEY (`id`),
KEY `post_id` (`post_id`),
KEY `expires_at` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' );
}
// ── set / get ────────────────────────────────────────────────────────
public function test_set_and_get_basic(): void {
WPDO_Zone_Warm::set( 1, 'test_key', 'hello' );
$this->assertSame( 'hello', WPDO_Zone_Warm::get( 1, 'test_key' ) );
}
public function test_get_returns_null_for_missing_key(): void {
$this->assertNull( WPDO_Zone_Warm::get( 9999, 'no_such_key' ) );
}
public function test_set_overwrites_existing_value(): void {
WPDO_Zone_Warm::set( 2, 'counter', '1' );
WPDO_Zone_Warm::set( 2, 'counter', '5' );
$this->assertSame( '5', WPDO_Zone_Warm::get( 2, 'counter' ) );
}
// ── TTL / expiry ─────────────────────────────────────────────────────
public function test_set_with_future_ttl_is_readable(): void {
WPDO_Zone_Warm::set( 3, 'flag', 'active', 3600 ); // expires in 1 hour
$this->assertSame( 'active', WPDO_Zone_Warm::get( 3, 'flag' ) );
}
public function test_expired_entry_returns_null(): void {
global $wpdb;
// Insert directly with a past expiry timestamp.
$wpdb->query(
"INSERT INTO `" . self::TABLE . "` (post_id, meta_key, meta_value, expires_at, created_at)
VALUES (4, 'old_flag', 'gone', '2000-01-01 00:00:00', '2000-01-01 00:00:00')"
);
$this->assertNull( WPDO_Zone_Warm::get( 4, 'old_flag' ) );
}
public function test_null_ttl_entry_never_expires(): void {
WPDO_Zone_Warm::set( 5, 'permanent', 'stays', null );
$this->assertSame( 'stays', WPDO_Zone_Warm::get( 5, 'permanent' ) );
}
// ── delete ───────────────────────────────────────────────────────────
public function test_delete_removes_specific_key(): void {
WPDO_Zone_Warm::set( 6, 'key_a', 'alpha' );
WPDO_Zone_Warm::set( 6, 'key_b', 'beta' );
WPDO_Zone_Warm::delete( 6, 'key_a' );
$this->assertNull( WPDO_Zone_Warm::get( 6, 'key_a' ) );
$this->assertSame( 'beta', WPDO_Zone_Warm::get( 6, 'key_b' ) );
}
public function test_delete_all_removes_all_keys_for_post(): void {
WPDO_Zone_Warm::set( 7, 'x', '1' );
WPDO_Zone_Warm::set( 7, 'y', '2' );
WPDO_Zone_Warm::set( 7, 'z', '3' );
WPDO_Zone_Warm::delete_all( 7 );
$this->assertNull( WPDO_Zone_Warm::get( 7, 'x' ) );
$this->assertNull( WPDO_Zone_Warm::get( 7, 'y' ) );
$this->assertNull( WPDO_Zone_Warm::get( 7, 'z' ) );
}
public function test_delete_all_does_not_affect_other_posts(): void {
WPDO_Zone_Warm::set( 8, 'shared_key', 'post_8' );
WPDO_Zone_Warm::set( 9, 'shared_key', 'post_9' );
WPDO_Zone_Warm::delete_all( 8 );
$this->assertNull( WPDO_Zone_Warm::get( 8, 'shared_key' ) );
$this->assertSame( 'post_9', WPDO_Zone_Warm::get( 9, 'shared_key' ) );
}
// ── purge_expired ────────────────────────────────────────────────────
public function test_purge_expired_removes_stale_entries(): void {
global $wpdb;
// One expired entry.
$wpdb->query(
"INSERT INTO `" . self::TABLE . "` (post_id, meta_key, meta_value, expires_at, created_at)
VALUES (10, 'stale', 'gone', '2000-01-01 00:00:00', '2000-01-01 00:00:00')"
);
// One valid entry.
WPDO_Zone_Warm::set( 10, 'fresh', 'keep', 3600 );
$deleted = WPDO_Zone_Warm::purge_expired();
$this->assertSame( 1, $deleted );
$this->assertNull( WPDO_Zone_Warm::get( 10, 'stale' ) );
$this->assertSame( 'keep', WPDO_Zone_Warm::get( 10, 'fresh' ) );
}
public function test_purge_expired_returns_zero_when_nothing_stale(): void {
WPDO_Zone_Warm::set( 11, 'live', 'value', 3600 );
$this->assertSame( 0, WPDO_Zone_Warm::purge_expired() );
}
// ── get_all ──────────────────────────────────────────────────────────
public function test_get_all_returns_all_valid_keys_for_post(): void {
WPDO_Zone_Warm::set( 12, 'ka', 'va' );
WPDO_Zone_Warm::set( 12, 'kb', 'vb' );
$all = WPDO_Zone_Warm::get_all( 12 );
$this->assertArrayHasKey( 'ka', $all );
$this->assertArrayHasKey( 'kb', $all );
$this->assertSame( 'va', $all['ka'] );
$this->assertSame( 'vb', $all['kb'] );
}
public function test_get_all_excludes_expired_entries(): void {
global $wpdb;
WPDO_Zone_Warm::set( 13, 'live', 'yes' );
$wpdb->query(
"INSERT INTO `" . self::TABLE . "` (post_id, meta_key, meta_value, expires_at, created_at)
VALUES (13, 'dead', 'no', '2000-01-01 00:00:00', '2000-01-01 00:00:00')"
);
$all = WPDO_Zone_Warm::get_all( 13 );
$this->assertArrayHasKey( 'live', $all );
$this->assertArrayNotHasKey( 'dead', $all );
}
}
+604
View File
@@ -0,0 +1,604 @@
<?php
declare(strict_types=1);
/**
* PHPUnit bootstrap for 2meet Data Optimizer integration tests.
*
* Connects to the real MariaDB database using a dedicated test prefix
* (wp_itest_) to avoid collisions with production data.
*
* Required environment variables:
* TMDO_TEST_DB_HOST (default: 127.0.0.1)
* TMDO_TEST_DB_USER (default: dbo)
* TMDO_TEST_DB_PASS (REQUIRED — no fallback)
* TMDO_TEST_DB_NAME (default: wp_wpdo_test)
*
* Back-compat aliases: also accepts WPDO_TEST_DB_* variable names.
*/
require_once dirname( __DIR__, 2 ) . '/vendor/autoload.php';
// ── Constants ──────────────────────────────────────────────────────────────
define( 'ABSPATH', '/fake/wordpress/' );
define( 'TMDO_PATH', dirname( __DIR__, 2 ) . '/' );
define( 'TMDO_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer/' );
define( 'TMDO_FILE', TMDO_PATH . '2meet-data-optimizer.php' );
$_plugin_header = file_get_contents( TMDO_FILE );
if ( false === $_plugin_header || ! preg_match( '/Version:\s*([0-9A-Za-z.\-+]+)/', $_plugin_header, $_ver ) ) {
throw new RuntimeException( 'Cannot read Version from plugin header: ' . TMDO_FILE );
}
define( 'TMDO_VERSION', $_ver[1] );
unset( $_plugin_header, $_ver );
define( 'TMDO_DB_VERSION', '2.0.0' );
define( 'TMDO_IS_SQLITE', false );
define( 'TMDO_IS_MYSQL', true );
if ( ! defined( 'TMDO_TABLE_PREFIX' ) ) { define( 'TMDO_TABLE_PREFIX', 'wpdo_' ); }
if ( ! defined( 'TMDO_CACHE_GROUP' ) ) { define( 'TMDO_CACHE_GROUP', 'wpdo' ); }
if ( ! defined( 'TMDO_MIN_PHP' ) ) { define( 'TMDO_MIN_PHP', '8.1' ); }
if ( ! defined( 'TMDO_MIN_WP' ) ) { define( 'TMDO_MIN_WP', '6.0' ); }
// Back-compat constants.
define( 'WPDO_PLUGIN_DIR', TMDO_PATH );
define( 'WPDO_PLUGIN_URL', TMDO_URL );
define( 'WPDO_PLUGIN_FILE', TMDO_FILE );
define( 'WPDO_VERSION', TMDO_VERSION );
define( 'WPDO_DB_VERSION', TMDO_DB_VERSION );
define( 'WPDO_IS_SQLITE', TMDO_IS_SQLITE );
define( 'WPDO_IS_MYSQL', TMDO_IS_MYSQL );
if ( ! defined( 'WPDO_TABLE_PREFIX' ) ) { define( 'WPDO_TABLE_PREFIX', TMDO_TABLE_PREFIX ); }
if ( ! defined( 'WPDO_CACHE_GROUP' ) ) { define( 'WPDO_CACHE_GROUP', TMDO_CACHE_GROUP ); }
// Tests force module states directly; the FSM guard would reject those jumps.
if ( ! defined( 'TMDO_FSM_GUARD_DISABLED' ) ) { define( 'TMDO_FSM_GUARD_DISABLED', true ); }
if ( ! defined( 'WPDO_FSM_GUARD_DISABLED' ) ) { define( 'WPDO_FSM_GUARD_DISABLED', TMDO_FSM_GUARD_DISABLED ); }
define( 'DAY_IN_SECONDS', 86400 );
define( 'HOUR_IN_SECONDS', 3600 );
define( 'MINUTE_IN_SECONDS', 60 );
if ( ! defined( 'OBJECT' ) ) { define( 'OBJECT', 'OBJECT' ); }
if ( ! defined( 'ARRAY_A' ) ) { define( 'ARRAY_A', 'ARRAY_A' ); }
// ── Database connection ────────────────────────────────────────────────────
$_db_host = getenv( 'TMDO_TEST_DB_HOST' ) ?: ( getenv( 'WPDO_TEST_DB_HOST' ) ?: '127.0.0.1' );
$_db_user = getenv( 'TMDO_TEST_DB_USER' ) ?: ( getenv( 'WPDO_TEST_DB_USER' ) ?: 'dbo' );
$_db_pass = getenv( 'TMDO_TEST_DB_PASS' ) ?: getenv( 'WPDO_TEST_DB_PASS' );
$_db_name = getenv( 'TMDO_TEST_DB_NAME' ) ?: ( getenv( 'WPDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test' );
if ( false === $_db_pass || '' === $_db_pass ) {
throw new RuntimeException(
'Integration tests require TMDO_TEST_DB_PASS (or WPDO_TEST_DB_PASS) environment variable. ' .
'Example: TMDO_TEST_DB_PASS=yourpass ./vendor/bin/phpunit --configuration phpunit-integration.xml'
);
}
$_mysqli_init = new mysqli( $_db_host, $_db_user, $_db_pass );
if ( $_mysqli_init->connect_error ) {
throw new RuntimeException( 'Integration test DB connection failed: ' . $_mysqli_init->connect_error );
}
$_db_name_quoted = '`' . str_replace( '`', '``', $_db_name ) . '`';
if ( ! $_mysqli_init->query( "CREATE DATABASE IF NOT EXISTS {$_db_name_quoted} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" ) ) {
throw new RuntimeException( "Failed to create test database '{$_db_name}': " . $_mysqli_init->error );
}
$_mysqli_init->close();
unset( $_mysqli_init, $_db_name_quoted );
$_mysqli = new mysqli( $_db_host, $_db_user, $_db_pass, $_db_name );
if ( $_mysqli->connect_error ) {
throw new RuntimeException( 'Integration test DB connection failed: ' . $_mysqli->connect_error );
}
$_mysqli->set_charset( 'utf8mb4' );
// ── Real $wpdb ─────────────────────────────────────────────────────────────
global $wpdb;
$wpdb = new class( $_mysqli ) {
private mysqli $db;
public string $prefix = 'wp_itest_';
public string $postmeta = 'wp_itest_postmeta';
public string $posts = 'wp_itest_posts';
public string $options = 'wp_itest_options';
public string $usermeta = 'wp_itest_usermeta';
public string $users = 'wp_itest_users';
public string $terms = 'wp_itest_terms';
public string $termmeta = 'wp_itest_termmeta';
public string $comments = 'wp_itest_comments';
public string $commentmeta = 'wp_itest_commentmeta';
public int $insert_id = 0;
public string $last_error = '';
public function __construct( mysqli $db ) { $this->db = $db; }
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sdf])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
if ( $m[1] === 'd' ) { return (string) (int) $val; }
if ( $m[1] === 'f' ) { return (string) (float) $val; }
return "'" . $this->db->real_escape_string( (string) $val ) . "'";
}, $sql );
}
public function get_var( string $sql ): ?string {
$result = $this->db->query( $sql );
if ( ! $result || ! ( $row = $result->fetch_row() ) ) { return null; }
return $row[0] !== null ? (string) $row[0] : null;
}
public function get_row( string $sql, $output = 'OBJECT' ) {
$result = $this->db->query( $sql );
if ( ! $result ) { return null; }
return ARRAY_A === $output ? ( $result->fetch_assoc() ?: null ) : ( $result->fetch_object() ?: null );
}
public function get_results( string $sql, $output = 'OBJECT' ): array {
$result = $this->db->query( $sql );
if ( ! $result ) { return []; }
$rows = [];
while ( $row = ( ARRAY_A === $output ? $result->fetch_assoc() : $result->fetch_object() ) ) {
$rows[] = $row;
}
return $rows;
}
public function get_col( string $sql, int $col_index = 0 ): array {
$result = $this->db->query( $sql );
if ( ! $result ) { return []; }
$values = [];
while ( $row = $result->fetch_row() ) { $values[] = $row[ $col_index ] ?? null; }
return $values;
}
public function get_charset_collate(): string {
return 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci';
}
public function insert( string $table, array $data, $format = null ): int|false {
$cols = implode( ', ', array_map( fn( $c ) => '`' . $c . '`', array_keys( $data ) ) );
$vals = implode( ', ', array_map(
fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'",
array_values( $data )
) );
$ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" );
if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->insert_id; }
$this->last_error = (string) $this->db->error;
return false;
}
public function update( string $table, array $data, array $where, $format = null, $wf = null ): int|false {
$set = implode( ', ', array_map( fn( $k, $v ) => '`' . $k . '` = ' . ( $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'" ), array_keys( $data ), $data ) );
$cond = implode( ' AND ', array_map( fn( $k, $v ) => '`' . $k . '` = ' . ( $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'" ), array_keys( $where ), $where ) );
$ok = $this->db->query( "UPDATE `{$table}` SET {$set} WHERE {$cond}" );
return $ok ? $this->db->affected_rows : false;
}
public function delete( string $table, array $where, $format = null ): int|false {
$cond = implode( ' AND ', array_map( fn( $k, $v ) => '`' . $k . '` = ' . ( $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'" ), array_keys( $where ), $where ) );
$ok = $this->db->query( "DELETE FROM `{$table}` WHERE {$cond}" );
return $ok ? $this->db->affected_rows : false;
}
public function replace( string $table, array $data, $format = null ): int|false {
$cols = implode( ', ', array_map( fn( $c ) => '`' . $c . '`', array_keys( $data ) ) );
$vals = implode( ', ', array_map( fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'", array_values( $data ) ) );
$ok = $this->db->query( "REPLACE INTO `{$table}` ({$cols}) VALUES ({$vals})" );
if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->db->affected_rows; }
$this->last_error = (string) $this->db->error;
return false;
}
public function esc_like( string $s ): string { return addcslashes( $s, '_%\\' ); }
public function flush(): void {}
public function query( string $sql ): int|bool {
$result = $this->db->query( $sql );
if ( $result instanceof mysqli_result ) { $result->free(); return true; }
if ( false === $result ) { return false; }
return $this->db->affected_rows;
}
};
// ── WordPress function stubs ───────────────────────────────────────────────
if ( ! function_exists( 'trailingslashit' ) ) {
function trailingslashit( string $s ): string { return rtrim( $s, '/' ) . '/'; }
}
if ( ! function_exists( 'sanitize_key' ) ) {
function sanitize_key( string $key ): string { return strtolower( preg_replace( '/[^a-z0-9_\-]/', '', $key ) ); }
}
if ( ! function_exists( 'absint' ) ) {
function absint( $v ): int { return abs( (int) $v ); }
}
if ( ! function_exists( 'wp_unslash' ) ) {
function wp_unslash( $v ) { return is_string( $v ) ? stripslashes( $v ) : $v; }
}
if ( ! function_exists( 'esc_like' ) ) {
function esc_like( string $s ): string { return addcslashes( $s, '_%\\' ); }
}
if ( ! function_exists( 'current_time' ) ) {
function current_time( string $type, bool $gmt = false ): string|int {
if ( 'timestamp' === $type || 'U' === $type ) { return time(); }
return gmdate( 'Y-m-d H:i:s' );
}
}
if ( ! function_exists( 'wp_parse_args' ) ) {
function wp_parse_args( $args, array $defaults = [] ): array {
if ( is_string( $args ) ) { parse_str( $args, $args ); }
return array_merge( $defaults, (array) $args );
}
}
$GLOBALS['_wp_filter_callbacks'] = [];
if ( ! function_exists( 'add_filter' ) ) {
function add_filter( string $hook, $cb, int $p = 10, int $a = 1 ): bool {
$GLOBALS['_wp_filter_callbacks'][ $hook ][] = $cb;
return true;
}
}
if ( ! function_exists( 'add_action' ) ) {
function add_action( string $hook, $cb, int $p = 10, int $a = 1 ): bool {
$GLOBALS['_wp_filter_callbacks'][ $hook ][] = $cb;
return true;
}
}
if ( ! function_exists( 'remove_filter' ) ) {
function remove_filter( string $hook, $cb, int $p = 10 ): bool {
if ( isset( $GLOBALS['_wp_filter_callbacks'][ $hook ] ) ) {
$GLOBALS['_wp_filter_callbacks'][ $hook ] = array_values(
array_filter( $GLOBALS['_wp_filter_callbacks'][ $hook ], fn( $c ) => $c !== $cb )
);
}
return true;
}
}
if ( ! function_exists( 'apply_filters' ) ) {
function apply_filters( string $hook, $value, ...$args ) {
foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) {
$value = $cb( $value, ...$args );
}
return $value;
}
}
if ( ! function_exists( 'do_action' ) ) {
function do_action( string $hook, ...$args ): void {
foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) {
$cb( ...$args );
}
}
}
if ( ! function_exists( 'is_admin' ) ) { function is_admin(): bool { return ! empty( $GLOBALS['_wp_is_admin'] ); } }
if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ): bool { return false; } }
if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } }
if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } }
if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } }
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { return true; } }
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } }
if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } }
if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } }
if ( ! function_exists( 'esc_html' ) ) { function esc_html( $s ): string { return htmlspecialchars( (string) $s, ENT_QUOTES, 'UTF-8' ); } }
if ( ! function_exists( 'esc_attr' ) ) { function esc_attr( string $s ): string { return htmlspecialchars( $s, ENT_QUOTES, 'UTF-8' ); } }
if ( ! function_exists( 'esc_url' ) ) { function esc_url( string $url ): string { return filter_var( $url, FILTER_SANITIZE_URL ) ?: ''; } }
if ( ! function_exists( 'esc_sql' ) ) { function esc_sql( $s ): string { return addslashes( is_string( $s ) ? $s : (string) $s ); } }
if ( ! function_exists( '_doing_it_wrong' ) ) { function _doing_it_wrong( string $fn, string $msg, string $ver ): void {} }
if ( ! function_exists( 'wp_strip_all_tags' ) ) { function wp_strip_all_tags( string $s ): string { return strip_tags( $s ); } }
if ( ! function_exists( 'get_option' ) ) { function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; } }
if ( ! function_exists( 'update_option' ) ) { function update_option( string $key, $value ): bool { $GLOBALS['_wp_options'][ $key ] = $value; return true; } }
if ( ! function_exists( 'delete_option' ) ) { function delete_option( string $key ): bool { unset( $GLOBALS['_wp_options'][ $key ] ); return true; } }
if ( ! function_exists( 'get_transient' ) ) { function get_transient( string $key ) { return $GLOBALS['_wp_transients'][ $key ] ?? false; } }
if ( ! function_exists( 'set_transient' ) ) { function set_transient( string $key, $value, int $exp = 0 ): bool { $GLOBALS['_wp_transients'][ $key ] = $value; return true; } }
if ( ! function_exists( 'delete_transient' ) ) { function delete_transient( string $key ): bool { unset( $GLOBALS['_wp_transients'][ $key ] ); return true; } }
if ( ! function_exists( 'get_post_meta' ) ) { function get_post_meta( int $post_id, string $key = '', bool $single = false ) { return $GLOBALS['_wp_postmeta'][ $post_id ][ $key ] ?? ( $single ? '' : [] ); } }
if ( ! function_exists( 'update_post_meta' ) ) {
function update_post_meta( int $post_id, string $key, $value, $prev = '' ): int|bool {
global $wpdb;
$has_table = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->postmeta ) );
if ( $has_table ) {
$existing = $wpdb->get_var( $wpdb->prepare( "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1", $post_id, $key ) );
if ( $existing ) {
$wpdb->update( $wpdb->postmeta, array( 'meta_value' => is_scalar( $value ) ? (string) $value : maybe_serialize( $value ) ), array( 'meta_id' => $existing ) );
} else {
$wpdb->insert( $wpdb->postmeta, array( 'post_id' => $post_id, 'meta_key' => $key, 'meta_value' => is_scalar( $value ) ? (string) $value : maybe_serialize( $value ) ) );
}
}
$GLOBALS['_wp_postmeta'][ $post_id ][ $key ] = $value;
return true;
}
}
if ( ! function_exists( 'get_user_meta' ) ) { function get_user_meta( int $uid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_usermeta'][ $uid ][ $key ] ?? ( $single ? '' : [] ); } }
if ( ! function_exists( 'update_user_meta' ) ) { function update_user_meta( int $uid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_usermeta'][ $uid ][ $key ] = $value; return true; } }
if ( ! function_exists( 'get_term_meta' ) ) { function get_term_meta( int $tid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_termmeta'][ $tid ][ $key ] ?? ( $single ? '' : [] ); } }
if ( ! function_exists( 'update_term_meta' ) ) { function update_term_meta( int $tid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; return true; } }
if ( ! function_exists( 'get_comment_meta' ) ) { function get_comment_meta( int $cid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); } }
if ( ! function_exists( 'update_comment_meta' ) ) { function update_comment_meta( int $cid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; return true; } }
if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } }
if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } }
if ( ! function_exists( 'is_post_publicly_viewable' ) ) {
function is_post_publicly_viewable( $post_id ): bool {
if ( isset( $GLOBALS['_wp_post_publicly_viewable'][ (int) $post_id ] ) ) { return (bool) $GLOBALS['_wp_post_publicly_viewable'][ (int) $post_id ]; }
return 'publish' === ( $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish' );
}
}
if ( ! function_exists( 'current_user_can' ) ) {
function current_user_can( string $cap, ...$args ): bool {
if ( ! empty( $args ) ) { $key = $cap . ':' . implode( ',', array_map( 'strval', $args ) ); if ( isset( $GLOBALS['_wp_current_user_can'][ $key ] ) ) { return (bool) $GLOBALS['_wp_current_user_can'][ $key ]; } }
return $GLOBALS['_wp_current_user_can'][ $cap ] ?? false;
}
}
if ( ! function_exists( 'get_current_user_id' ) ) { function get_current_user_id(): int { return (int) ( $GLOBALS['_wp_current_user_id'] ?? 0 ); } }
if ( ! function_exists( 'is_multisite' ) ) { function is_multisite(): bool { return (bool) ( $GLOBALS['_wp_is_multisite'] ?? false ); } }
if ( ! function_exists( 'is_super_admin' ) ) { function is_super_admin( ?int $uid = null ): bool { return (bool) ( $GLOBALS['_wp_is_super_admin'] ?? false ); } }
if ( ! function_exists( 'switch_to_blog' ) ) { function switch_to_blog( int $blog_id ): bool { $GLOBALS['_wp_current_blog_id'] = $blog_id; return true; } }
if ( ! function_exists( 'restore_current_blog' ) ) { function restore_current_blog(): bool { unset( $GLOBALS['_wp_current_blog_id'] ); return true; } }
if ( ! function_exists( 'get_sites' ) ) { function get_sites( array $args = [] ): array { return $GLOBALS['_wp_sites'] ?? []; } }
if ( ! function_exists( 'is_plugin_active_for_network' ) ) { function is_plugin_active_for_network( string $plugin ): bool { return (bool) ( $GLOBALS['_wp_plugin_active_for_network'][ $plugin ] ?? false ); } }
if ( ! function_exists( 'is_plugin_active' ) ) { function is_plugin_active( string $plugin ): bool { return false; } }
if ( ! function_exists( 'deactivate_plugins' ) ) { function deactivate_plugins( $plugin, bool $silent = false ): void {} }
if ( ! function_exists( 'plugin_basename' ) ) { function plugin_basename( string $file ): string { return basename( dirname( $file ) ) . '/' . basename( $file ); } }
if ( ! function_exists( 'wp_generate_password' ) ) { function wp_generate_password( int $len = 12, bool $special = true ): string { return substr( str_replace( [ '/', '+', '=' ], '', base64_encode( random_bytes( $len ) ) ), 0, $len ); } }
if ( ! function_exists( 'wp_json_encode' ) ) { function wp_json_encode( $data, int $flags = 0 ): string|false { return json_encode( $data, $flags ); } }
if ( ! function_exists( 'wp_rand' ) ) { function wp_rand( int $min = 0, int $max = 0 ): int { return random_int( $min, $max ?: PHP_INT_MAX ); } }
if ( ! function_exists( 'is_wp_error' ) ) { function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; } }
if ( ! function_exists( 'wp_verify_nonce' ) ) { function wp_verify_nonce( $nonce, string $action = '' ) { return $GLOBALS['_wp_valid_nonces'][ (string) $nonce ] ?? false; } }
if ( ! function_exists( 'wp_create_nonce' ) ) { function wp_create_nonce( string $action = '' ): string { $nonce = 'test_nonce_' . md5( $action ); $GLOBALS['_wp_valid_nonces'][ $nonce ] = 1; return $nonce; } }
if ( ! function_exists( 'wp_die' ) ) { function wp_die( $message = '' ): void { throw new RuntimeException( is_string( $message ) ? $message : 'wp_die' ); } }
if ( ! function_exists( 'maybe_serialize' ) ) { function maybe_serialize( $data ) { return is_array( $data ) || is_object( $data ) ? serialize( $data ) : $data; } }
if ( ! function_exists( 'maybe_unserialize' ) ) { function maybe_unserialize( $value ) { if ( ! is_string( $value ) ) { return $value; } $u = @unserialize( $value ); return ( false !== $u || 'b:0;' === $value ) ? $u : $value; } }
if ( ! function_exists( 'get_bloginfo' ) ) { function get_bloginfo( string $key ): string { return 'version' === $key ? '6.9.4' : ''; } }
if ( ! function_exists( 'sanitize_title' ) ) { function sanitize_title( string $title ): string { return strtolower( preg_replace( '/[^a-z0-9-]+/i', '-', trim( $title ) ) ); } }
if ( ! function_exists( 'taxonomy_exists' ) ) {
function taxonomy_exists( string $taxonomy ): bool {
if ( isset( $GLOBALS['_taxonomy_exists_override'] ) ) { return (bool) $GLOBALS['_taxonomy_exists_override']; }
return in_array( $taxonomy, [ 'category', 'post_tag', 'listing_category', 'listing_tag' ], true );
}
}
if ( ! function_exists( 'clean_term_cache' ) ) { function clean_term_cache( $ids, string $taxonomy = '', bool $clean_taxonomy = true ): void {} }
if ( ! function_exists( 'get_taxonomies' ) ) {
function get_taxonomies( array $args = [], string $output = 'names' ): array {
$taxonomies = [ 'category', 'post_tag', 'listing_category', 'listing_tag' ];
if ( 'objects' === $output ) {
$out = [];
foreach ( $taxonomies as $slug ) { $out[ $slug ] = (object) [ 'name' => $slug, 'labels' => (object) [ 'singular_name' => ucfirst( str_replace( '_', ' ', $slug ) ) ] ]; }
return $out;
}
return $taxonomies;
}
}
if ( ! function_exists( 'is_serialized' ) ) { function is_serialized( $data ): bool { return is_string( $data ) && strlen( $data ) >= 4 && in_array( $data[0], [ 'a', 's', 'i', 'd', 'b', 'O', 'N' ], true ) && str_ends_with( $data, ';' ); } }
if ( ! function_exists( 'wp_upload_dir' ) ) {
function wp_upload_dir( $time = null, $create_dir = true, $refresh_cache = false ): array {
$upload_path = sys_get_temp_dir() . '/wp-uploads-test';
return [
'path' => $upload_path,
'url' => 'http://localhost/wp-content/uploads',
'subdir' => '',
'basedir' => $upload_path,
'baseurl' => 'http://localhost/wp-content/uploads',
'error' => false,
];
}
}
if ( ! function_exists( 'wp_mkdir_p' ) ) {
function wp_mkdir_p( string $dir ): bool {
if ( is_dir( $dir ) ) { return true; }
return mkdir( $dir, 0777, true );
}
}
$GLOBALS['_wp_cache'] = [];
if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } }
if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } }
if ( ! function_exists( 'wp_cache_delete' ) ) { function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; } }
if ( ! class_exists( 'WP_Query' ) ) {
class WP_Query {
private array $vars = [];
public function get( string $key, $default = '' ) { return $this->vars[ $key ] ?? $default; }
public function set( string $key, $value ): void { $this->vars[ $key ] = $value; }
}
}
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
private string $code;
private string $message;
public function __construct( string $code = '', string $message = '' ) { $this->code = $code; $this->message = $message; }
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
}
}
if ( ! class_exists( 'WP_REST_Request' ) ) {
class WP_REST_Request {
private array $params = []; private array $headers = [];
public function __construct( string $method = 'GET', string $route = '' ) {}
public function get_param( string $key ) { return $this->params[ $key ] ?? null; }
public function set_param( string $key, $value ): void { $this->params[ $key ] = $value; }
public function get_header( string $key ): ?string { return $this->headers[ strtolower( $key ) ] ?? null; }
public function set_header( string $key, string $value ): void { $this->headers[ strtolower( $key ) ] = $value; }
}
}
if ( ! class_exists( 'WP_REST_Response' ) ) {
class WP_REST_Response {
private $data; private int $status; private array $headers = [];
public function __construct( $data = null, int $status = 200 ) { $this->data = $data; $this->status = $status; }
public function get_data() { return $this->data; }
public function get_status(): int { return $this->status; }
public function header( string $k, string $v ): void { $this->headers[ $k ] = $v; }
public function get_headers(): array { return $this->headers; }
}
}
if ( ! class_exists( 'WP_REST_Server' ) ) {
class WP_REST_Server { const READABLE = 'GET'; const CREATABLE = 'POST'; }
}
if ( ! function_exists( 'register_rest_route' ) ) { function register_rest_route( string $ns, string $route, array $args ): bool { return true; } }
if ( ! function_exists( 'dbDelta' ) ) {
function dbDelta( string $sql ): array {
global $wpdb;
$sql_idempotent = preg_replace( '/^CREATE TABLE/i', 'CREATE TABLE IF NOT EXISTS', trim( $sql ), 1 );
$wpdb->query( $sql_idempotent );
return [];
}
}
if ( ! function_exists( 'wp_insert_post' ) ) {
function wp_insert_post( array $postarr, bool $wp_error = false ) {
global $wpdb;
$now = current_time( 'mysql' );
$defaults = [ 'post_title' => '', 'post_type' => 'post', 'post_status' => 'publish', 'post_content' => '', 'post_excerpt' => '', 'post_content_filtered' => '', 'to_ping' => '', 'pinged' => '', 'post_date' => $now, 'post_date_gmt' => $now, 'post_modified' => $now, 'post_modified_gmt' => $now, 'post_name' => '', 'guid' => '' ];
$row = array_merge( $defaults, $postarr );
if ( '' === $row['post_name'] ) { $row['post_name'] = sanitize_title( (string) $row['post_title'] ); }
$ok = $wpdb->insert( $wpdb->posts, $row );
return $ok ? (int) $wpdb->insert_id : ( $wp_error ? new WP_Error( 'insert_failed', 'Insert failed' ) : 0 );
}
}
if ( ! function_exists( 'wp_insert_term' ) ) {
function wp_insert_term( string $term, string $taxonomy, array $args = [] ) {
global $wpdb;
$slug = $args['slug'] ?? sanitize_title( $term );
$ok = $wpdb->insert( $wpdb->terms, [ 'name' => $term, 'slug' => $slug, 'term_group' => 0 ] );
if ( ! $ok ) { return new WP_Error( 'insert_failed', 'terms insert failed' ); }
$term_id = (int) $wpdb->insert_id;
$wpdb->insert( $wpdb->prefix . 'term_taxonomy', [ 'term_id' => $term_id, 'taxonomy' => $taxonomy, 'description' => '', 'parent' => 0, 'count' => 0 ] );
return [ 'term_id' => $term_id, 'term_taxonomy_id' => (int) $wpdb->insert_id ];
}
}
if ( ! function_exists( 'wp_insert_comment' ) ) {
function wp_insert_comment( array $data ) {
global $wpdb;
$ok = $wpdb->insert( $wpdb->comments, [
'comment_post_ID' => (int) ( $data['comment_post_ID'] ?? 0 ),
'comment_author' => (string) ( $data['comment_author'] ?? '' ),
'comment_author_email' => (string) ( $data['comment_author_email'] ?? '' ),
'comment_author_url' => (string) ( $data['comment_author_url'] ?? '' ),
'comment_author_IP' => (string) ( $data['comment_author_IP'] ?? '127.0.0.1' ),
'comment_date' => (string) ( $data['comment_date'] ?? gmdate( 'Y-m-d H:i:s' ) ),
'comment_date_gmt' => (string) ( $data['comment_date_gmt'] ?? gmdate( 'Y-m-d H:i:s' ) ),
'comment_content' => (string) ( $data['comment_content'] ?? '' ),
'comment_karma' => (int) ( $data['comment_karma'] ?? 0 ),
'comment_approved' => (string) ( $data['comment_approved'] ?? '1' ),
'comment_agent' => (string) ( $data['comment_agent'] ?? '' ),
'comment_type' => (string) ( $data['comment_type'] ?? 'comment' ),
'comment_parent' => (int) ( $data['comment_parent'] ?? 0 ),
'user_id' => (int) ( $data['user_id'] ?? 0 ),
] );
if ( ! $ok ) { return false; }
return (int) $wpdb->insert_id;
}
}
if ( ! class_exists( 'WP_CLI' ) ) {
class WP_CLI {
public static function log( string $msg ): void {}
public static function warning( string $msg ): void {}
public static function success( string $msg ): void {}
public static function error( string $msg ): void {}
public static function add_command( string $name, $class ): void {}
}
}
// ── Load plugin classes ────────────────────────────────────────────────────
require_once TMDO_PATH . 'includes/class-tmdo-capability.php';
require_once TMDO_PATH . 'includes/class-tmdo-crypto.php';
require_once TMDO_PATH . 'includes/class-tmdo-safe-unserialize.php';
require_once TMDO_PATH . 'includes/class-tmdo-db.php';
require_once TMDO_PATH . 'includes/class-tmdo-logger.php';
require_once TMDO_PATH . 'includes/class-tmdo-feature-flags.php';
require_once TMDO_PATH . 'includes/class-tmdo-sqlite-compat.php';
require_once TMDO_PATH . 'includes/class-tmdo-installer.php';
require_once TMDO_PATH . 'includes/class-tmdo-schema-registry.php';
require_once TMDO_PATH . 'includes/class-tmdo-custom-table-registry.php';
require_once TMDO_PATH . 'includes/trait-tmdo-anti-eav-aware.php';
require_once TMDO_PATH . 'includes/class-tmdo-hook-bus-bridge.php';
require_once TMDO_PATH . 'includes/class-tmdo-conflict-monitor.php';
require_once TMDO_PATH . 'includes/class-tmdo-compatibility.php';
require_once TMDO_PATH . 'includes/interceptors/class-tmdo-interceptor-base.php';
require_once TMDO_PATH . 'includes/interceptors/class-tmdo-sync-bridge.php';
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-hot.php';
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-warm.php';
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-cold.php';
require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-archive.php';
require_once TMDO_PATH . 'includes/query/class-tmdo-query-interceptor-base.php';
require_once TMDO_PATH . 'includes/query/class-tmdo-query-router.php';
require_once TMDO_PATH . 'includes/query/class-tmdo-post-query-router.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-base.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-engine.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-hot-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-warm-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-cold-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-archive-migration.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-term-comment-garbage-filter.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-term-comment-misc-bucket.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-member-fields.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-post-fields.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-points-manager.php';
require_once TMDO_PATH . 'includes/integrations/class-tmdo-demo-entity-counter.php';
require_once TMDO_PATH . 'includes/class-tmdo-cache-layer.php';
require_once TMDO_PATH . 'includes/class-tmdo-zone-classifier.php';
require_once TMDO_PATH . 'includes/class-tmdo-api.php';
require_once TMDO_PATH . 'includes/class-tmdo-v2-upgrader.php';
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-manager.php';
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-writer.php';
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-reader.php';
require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-pruner.php';
require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php';
require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-cache-orchestrator.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-query-compiler.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-schema-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php';
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-term.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-comment.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-commentmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-term-comment-shadow-verifier.php';
require_once TMDO_PATH . 'includes/class-tmdo-term-comment-backfill.php';
require_once TMDO_PATH . 'includes/class-tmdo-term-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-comment-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php';
require_once TMDO_PATH . 'includes/class-tmdo-core.php';
// Back-compat aliases (WPDO_* → TMDO_*).
require_once TMDO_PATH . 'includes/class-tmdo-back-compat.php';
// FSM Guard bypass for integration tests (real DB transitions should work).
if ( ! function_exists( '__return_true' ) ) {
function __return_true(): bool { return true; }
}
add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
// TMDO_Listing_Stats moved to HP AddOn; stub here for tests that reference it.
if ( ! class_exists( 'TMDO_Listing_Stats' ) ) {
class TMDO_Listing_Stats {
public static function register(): void {}
public static function get_view_count( int $post_id ): int { return 0; }
public static function increment_view( int $post_id, string $ip = '' ): int { return 0; }
public static function is_rate_limited( int $post_id, string $ip ): bool { return false; }
public static function flush_views_to_postmeta(): int { return 0; }
}
class_alias( 'TMDO_Listing_Stats', 'WPDO_Listing_Stats' );
}
+140
View File
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
if ( ! function_exists( '__' ) ) {
function __( string $text, string $domain = 'default' ): string {
return $text;
}
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-feature-flags.php';
require_once dirname( __DIR__, 3 ) . '/includes/advisor/class-tmdo-fsm-advisor.php';
/**
* Unit tests for WPDO_FSM_Advisor (v2.4.0 M12).
*
* Pure-logic tests — uses option-driven state injection (no real DB).
*/
class FSMAdvisorTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public string $postmeta = 'wp_postmeta';
public string $options = 'wp_options';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%[sd]/', function() use ( &$i, $args ) {
return (string) ( $args[ $i++ ] ?? '?' );
}, $sql );
}
public function get_var( string $sql ) { return '0'; } // shadow_diffs table absent
public function get_results( string $sql, $output = ARRAY_A ): array { return array(); }
};
}
private function set_module_state( string $module, string $state, ?int $days_ago = null ): void {
// Set FSM state.
$flags = (array) get_option( 'wpdo_features', array() );
$flags[ $module ] = $state;
update_option( 'wpdo_features', $flags, false );
// Reset Feature_Flags request cache via reflection.
$ref = new ReflectionClass( WPDO_Feature_Flags::class );
$prop = $ref->getProperty( 'cache' );
$prop->setAccessible( true );
$prop->setValue( null, null );
if ( null !== $days_ago ) {
$entered = (array) get_option( 'wpdo_fsm_state_entered', array() );
$entered[ $module ] = array(
'state' => $state,
'entered_at' => gmdate( 'Y-m-d H:i:s', time() - $days_ago * 86400 ),
);
update_option( 'wpdo_fsm_state_entered', $entered, false );
}
}
public function test_idle_state_returns_HOLD(): void {
$this->set_module_state( 'reviews', 'idle' );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'HOLD', $advice['action'] );
$this->assertSame( 'info', $advice['level'] );
}
public function test_complete_state_returns_HOLD(): void {
$this->set_module_state( 'reviews', 'complete' );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'HOLD', $advice['action'] );
}
public function test_dual_write_with_short_soak_returns_WAIT(): void {
// In dual_write 0 days < 1 day min soak.
$this->set_module_state( 'reviews', 'dual_write', 0 );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'WAIT', $advice['action'] );
$this->assertGreaterThanOrEqual( 1, $advice['days_remaining'] );
}
public function test_dual_write_after_min_soak_returns_PROMOTE(): void {
$this->set_module_state( 'reviews', 'dual_write', 2 );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'PROMOTE', $advice['action'] );
$this->assertSame( 'backfill', $advice['next_state'] );
}
public function test_verify_with_long_soak_returns_PROMOTE(): void {
// verify needs 7 days; 10 days = should promote.
$this->set_module_state( 'reviews', 'verify', 10 );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'PROMOTE', $advice['action'] );
$this->assertSame( 'cutover', $advice['next_state'] );
}
public function test_verify_with_short_soak_returns_WAIT(): void {
$this->set_module_state( 'reviews', 'verify', 3 );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'WAIT', $advice['action'] );
$this->assertSame( 4, $advice['days_remaining'] );
}
public function test_cleanup_with_short_wash_returns_WAIT(): void {
$this->set_module_state( 'reviews', 'cleanup', 1 );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'WAIT', $advice['action'] );
$this->assertSame( 2, $advice['days_remaining'] );
}
public function test_cleanup_after_wash_returns_PROMOTE(): void {
$this->set_module_state( 'reviews', 'cleanup', 5 );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'PROMOTE', $advice['action'] );
$this->assertSame( 'complete', $advice['next_state'] );
}
public function test_advice_metrics_include_state_and_soak(): void {
$this->set_module_state( 'reviews', 'dual_write', 5 );
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
$this->assertSame( 'dual_write', $advice['metrics']['state'] );
$this->assertSame( 5, $advice['metrics']['days_in_state'] );
$this->assertSame( 1, $advice['metrics']['min_soak_days'] );
}
public function test_advise_all_returns_map_for_all_modules(): void {
$advice = WPDO_FSM_Advisor::advise_all();
$this->assertNotEmpty( $advice );
// All known HPCT + zone modules should be present (default idle).
foreach ( WPDO_Feature_Flags::HPCT_MODULES as $m ) {
$this->assertArrayHasKey( $m, $advice );
}
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
if ( ! function_exists( '__' ) ) {
function __( string $text, string $domain = 'default' ): string { return $text; }
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-feature-flags.php';
require_once dirname( __DIR__, 3 ) . '/includes/advisor/class-tmdo-fsm-advisor.php';
require_once dirname( __DIR__, 3 ) . '/includes/diagnostic/class-tmdo-health-cron.php';
require_once dirname( __DIR__, 3 ) . '/includes/advisor/class-tmdo-fsm-automator.php';
/**
* Unit tests for WPDO_FSM_Automator (v2.5.0 M13).
*/
class FSMAutomatorTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
// Reset Feature_Flags cache.
$ref = new ReflectionClass( WPDO_Feature_Flags::class );
$prop = $ref->getProperty( 'cache' );
$prop->setAccessible( true );
$prop->setValue( null, null );
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public string $postmeta = 'wp_postmeta';
public string $options = 'wp_options';
public function prepare( string $sql, ...$args ): string { return $sql; }
public function get_var( string $sql ) { return '0'; }
public function get_results( string $sql, $output = ARRAY_A ): array { return array(); }
};
}
public function test_default_disabled(): void {
$this->assertFalse( WPDO_FSM_Automator::is_enabled() );
}
public function test_run_returns_disabled_when_off(): void {
$result = WPDO_FSM_Automator::run();
$this->assertFalse( $result['ok'] );
$this->assertContains( 'automator disabled', $result['errors'] );
}
public function test_run_blocked_by_critical_cool_off(): void {
// Enable + simulate critical alert in last run.
update_option( 'wpdo_automator_enabled', '1', false );
update_option( WPDO_Health_Cron::OPTION_LAST_RUN, array(
'critical_count' => 2,
'ran_at' => gmdate( 'Y-m-d H:i:s' ),
), false );
$result = WPDO_FSM_Automator::run();
$this->assertFalse( $result['ok'] );
$this->assertStringContainsString( 'cool-off', $result['errors'][0] );
}
public function test_run_proceeds_when_no_critical(): void {
update_option( 'wpdo_automator_enabled', '1', false );
update_option( WPDO_Health_Cron::OPTION_LAST_RUN, array(
'critical_count' => 0,
'ran_at' => gmdate( 'Y-m-d H:i:s' ),
), false );
$result = WPDO_FSM_Automator::run();
$this->assertTrue( $result['ok'] );
// Most modules will be in idle and Advisor recommends WAIT (soak time);
// expected that 0 actions are executed in baseline test.
$this->assertGreaterThanOrEqual( 0, $result['executed'] );
}
public function test_blacklist_skips_module(): void {
update_option( 'wpdo_automator_enabled', '1', false );
update_option( 'wpdo_automator_blacklist', array( 'reviews', 'wc_orders' ), false );
update_option( WPDO_Health_Cron::OPTION_LAST_RUN, array(
'critical_count' => 0,
'ran_at' => gmdate( 'Y-m-d H:i:s' ),
), false );
$blacklist = WPDO_FSM_Automator::blacklist();
$this->assertContains( 'reviews', $blacklist );
$this->assertContains( 'wc_orders', $blacklist );
$result = WPDO_FSM_Automator::run();
$this->assertTrue( $result['ok'] );
// Skipped at least the 2 blacklisted ones.
$this->assertGreaterThanOrEqual( 2, $result['skipped'] );
}
public function test_forbidden_transitions_constant(): void {
$ref = new ReflectionClass( WPDO_FSM_Automator::class );
$constant = $ref->getReflectionConstant( 'FORBIDDEN_TRANSITIONS' );
$this->assertNotNull( $constant );
$value = $constant->getValue();
$this->assertContains( array( 'verify', 'cutover' ), $value );
$this->assertContains( array( 'cutover', 'cleanup' ), $value );
$this->assertContains( array( 'cleanup', 'complete' ), $value );
}
public function test_options_keys_match_admin_form(): void {
$this->assertSame( 'wpdo_automator_enabled', WPDO_FSM_Automator::OPT_ENABLED );
$this->assertSame( 'wpdo_automator_blacklist', WPDO_FSM_Automator::OPT_BLACKLIST );
$this->assertSame( 'wpdo_automator_last_action', WPDO_FSM_Automator::OPT_LAST_ACTION );
}
public function test_last_actions_returns_array(): void {
$this->assertSame( array(), WPDO_FSM_Automator::last_actions() );
update_option( 'wpdo_automator_last_action', array( 'reviews' => '2026-04-28 04:30:00' ), false );
$last = WPDO_FSM_Automator::last_actions();
$this->assertSame( '2026-04-28 04:30:00', $last['reviews'] );
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
if ( ! function_exists( '__' ) ) {
function __( string $text, string $domain = 'default' ): string { return $text; }
}
if ( ! function_exists( 'number_format_i18n' ) ) {
function number_format_i18n( $n, int $decimals = 0 ): string {
return number_format( (float) $n, $decimals );
}
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-feature-flags.php';
require_once dirname( __DIR__, 3 ) . '/includes/advisor/class-tmdo-module-rules.php';
require_once dirname( __DIR__, 3 ) . '/includes/advisor/class-tmdo-module-detector.php';
/**
* Unit tests for WPDO_Module_Rules + WPDO_Module_Detector (v2.5.0 M16).
*
* Pure-logic tests using mocked WPDO_Compatibility (via dynamic class
* substitution where needed) and option-driven post counts.
*/
class ModuleDetectorTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
// Reset Feature_Flags cache.
$ref = new ReflectionClass( WPDO_Feature_Flags::class );
$prop = $ref->getProperty( 'cache' );
$prop->setAccessible( true );
$prop->setValue( null, null );
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public string $posts = 'wp_posts';
public string $postmeta = 'wp_postmeta';
public string $options = 'wp_options';
public array $count_overrides = array();
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%[sd]/', function() use ( &$i, $args ) {
return is_string( $args[ $i ] ?? null ) ? "'" . $args[ $i++ ] . "'" : (string) ( $args[ $i++ ] ?? '?' );
}, $sql );
}
public function get_var( string $sql ) {
// Mock by inspecting SQL pattern.
if ( str_contains( $sql, "post_type =" ) ) {
if ( preg_match( "/post_type = '([^']+)'/", $sql, $m ) ) {
return (string) ( $this->count_overrides[ $m[1] ] ?? 0 );
}
}
if ( str_contains( $sql, "post_status = 'trash'" ) ) {
return (string) ( $this->count_overrides['__trash__'] ?? 0 );
}
return '0';
}
public function get_results( string $sql, $output = ARRAY_A ): array { return array(); }
};
}
public function test_module_rules_known_modules_count(): void {
$modules = WPDO_Module_Rules::known_modules();
$this->assertGreaterThanOrEqual( 14, count( $modules ), '預期 ≥ 14 個 module9 HPCT + 6 zone - 1 dup' );
$this->assertContains( 'reviews', $modules );
$this->assertContains( 'warm', $modules );
$this->assertContains( 'archive', $modules );
$this->assertContains( 'hot_hp_listing', $modules );
}
public function test_module_rule_for_module_returns_array(): void {
$rule = WPDO_Module_Rules::for_module( 'reviews' );
$this->assertIsArray( $rule );
$this->assertArrayHasKey( 'compat_required', $rule );
$this->assertArrayHasKey( 'description', $rule );
}
public function test_module_rule_unknown_module_returns_null(): void {
$this->assertNull( WPDO_Module_Rules::for_module( 'totally_fake_module_xyz' ) );
}
public function test_detector_skip_when_module_not_idle(): void {
// Set reviews to dual_write.
WPDO_Feature_Flags::set( 'reviews', 'dual_write' );
$r = WPDO_Module_Detector::detect_one( 'reviews' );
$this->assertSame( 'skip', $r['recommendation'] );
$this->assertFalse( $r['available'] );
$this->assertNotEmpty( $r['blockers'] );
$this->assertSame( 'dual_write', $r['current_state'] );
}
public function test_detector_warm_module_recommends_for_any_site(): void {
// warm module's compat_required is empty + no post_type.
$r = WPDO_Module_Detector::detect_one( 'warm' );
$this->assertTrue( $r['available'] );
$this->assertSame( 'enable', $r['recommendation'] );
$this->assertGreaterThan( 0, $r['confidence'] );
$this->assertNotEmpty( $r['suggested_action'] );
$this->assertSame( 'dual_write', $r['suggested_action']['to_state'] );
}
public function test_detector_archive_blocked_when_no_trashed_posts(): void {
// trashed = 0, threshold = 50.
global $wpdb;
$wpdb->count_overrides['__trash__'] = 0;
$r = WPDO_Module_Detector::detect_one( 'archive' );
$this->assertSame( 'wait', $r['recommendation'] );
$this->assertFalse( $r['available'] );
}
public function test_detector_archive_passes_when_enough_trashed(): void {
global $wpdb;
$wpdb->count_overrides['__trash__'] = 100;
$r = WPDO_Module_Detector::detect_one( 'archive' );
$this->assertSame( 'enable', $r['recommendation'] );
$this->assertTrue( $r['available'] );
}
public function test_detector_hivepress_required_blocks_when_inactive(): void {
// reviews requires hivepress; Compatibility class isn't loaded in this test.
// Without Compatibility class, the helper returns the full required list as missing.
$r = WPDO_Module_Detector::detect_one( 'reviews' );
$this->assertSame( 'skip', $r['recommendation'] );
$this->assertNotEmpty( $r['blockers'] );
}
public function test_get_actionable_filters_by_confidence(): void {
$actionable = WPDO_Module_Detector::get_actionable( 0.5 );
$this->assertIsArray( $actionable );
// Each result should have available=true and recommendation=enable.
foreach ( $actionable as $module => $r ) {
$this->assertTrue( $r['available'], "$module should be available" );
$this->assertSame( 'enable', $r['recommendation'] );
$this->assertGreaterThanOrEqual( 0.5, $r['confidence'] );
}
}
public function test_get_actionable_sorts_by_confidence_desc(): void {
$actionable = WPDO_Module_Detector::get_actionable( 0.0 );
$last_confidence = 1.0;
foreach ( $actionable as $r ) {
$this->assertLessThanOrEqual( $last_confidence, (float) $r['confidence'] );
$last_confidence = (float) $r['confidence'];
}
}
public function test_detect_all_returns_entry_for_each_known_module(): void {
$all = WPDO_Module_Detector::detect_all( true );
$known = WPDO_Module_Rules::known_modules();
foreach ( $known as $m ) {
$this->assertArrayHasKey( $m, $all, "Detector should return entry for $m" );
}
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for TMDO back-compat layer.
*
* Verifies that WPDO_* aliases for classes, traits, and interfaces all resolve
* correctly so sister plugins compiled against old names continue to work.
*/
class BackCompatTest extends TestCase {
// ── Trait alias ──────────────────────────────────────────────────────────
public function test_wpdo_anti_eav_aware_trait_exists(): void {
$this->assertTrue( trait_exists( 'WPDO_Anti_EAV_Aware' ), 'WPDO_Anti_EAV_Aware trait alias must exist' );
}
public function test_class_using_wpdo_anti_eav_aware_is_valid(): void {
// Verify a class can `use WPDO_Anti_EAV_Aware` without fatal error.
$obj = new class {
use WPDO_Anti_EAV_Aware;
};
// trait_exists check via class_uses — PHP doesn't support instanceof for traits.
$this->assertArrayHasKey( 'WPDO_Anti_EAV_Aware', class_uses( $obj ) );
}
// ── Interface alias ───────────────────────────────────────────────────────
public function test_wpdo_entity_adapter_interface_exists(): void {
$this->assertTrue(
interface_exists( 'WPDO_Entity_Adapter_Interface' ),
'WPDO_Entity_Adapter_Interface must exist as a back-compat alias'
);
}
public function test_tmdo_adapter_post_implements_wpdo_interface(): void {
// An object implementing TMDO_Entity_Adapter_Interface must also
// satisfy `instanceof WPDO_Entity_Adapter_Interface`.
$adapter = new TMDO_Adapter_Post();
$this->assertInstanceOf( 'WPDO_Entity_Adapter_Interface', $adapter );
}
// ── DB version consistency ────────────────────────────────────────────────
public function test_tmdo_db_version_constant_matches_installer_schema(): void {
// TMDO_DB_VERSION (plugin header constant) must equal TMDO_Installer::SCHEMA_VERSION
// (private). If they diverge, maybe_upgrade() either never fires or fires every boot.
$ref = new ReflectionClass( TMDO_Installer::class );
$schema_version = $ref->getConstant( 'SCHEMA_VERSION' );
$this->assertSame( TMDO_DB_VERSION, $schema_version,
'TMDO_DB_VERSION constant must match TMDO_Installer::SCHEMA_VERSION' );
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Cache_Layer — Object Cache integration for Zone C (Cold).
*/
class CacheLayerTest extends TestCase {
/** Rows returned by get_results() for the bulk-fetch query. */
public static array $db_rows = [];
protected function setUp(): void {
self::$db_rows = [];
$GLOBALS['_wp_cache'] = [];
// Reset Schema Registry singleton.
$ref = new ReflectionClass( WPDO_Schema_Registry::class );
$ref->getProperty( 'instance' )->setValue( null, null );
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
public function get_var( string $sql ): ?string { return null; }
public function get_row( string $sql, $output = OBJECT ) { return null; }
public function insert( string $table, array $data, $format = null ): int|false { return 1; }
public function update( string $table, array $data, array $where, $f = null, $wf = null ): int|false { return 1; }
public function delete( string $table, array $where, $format = null ): int|false { return 1; }
public function query( string $sql ): int|bool { return 1; }
public function get_results( string $sql, $output = OBJECT ): array {
return CacheLayerTest::$db_rows;
}
};
}
// ── Helper: register cold field ──────────────────────────────────────────
private function register_cold_field( string $post_type = 'hp_listing', string $meta_key = 'hp_description' ): void {
WPDO_Schema_Registry::instance()->register( 'test', [
'post_type' => $post_type,
'meta_key' => $meta_key,
'zone' => 'cold',
] );
}
// ── prefetch() ───────────────────────────────────────────────────────────
public function test_prefetch_returns_early_for_empty_post_ids(): void {
$this->register_cold_field();
WPDO_Cache_Layer::prefetch( [], 'hp_listing' );
$this->assertEmpty( $GLOBALS['_wp_cache'] );
}
public function test_prefetch_returns_early_when_no_cold_keys_for_type(): void {
// 'unknown_type' has no registered cold fields.
WPDO_Cache_Layer::prefetch( [ 1, 2, 3 ], 'unknown_type' );
$this->assertEmpty( $GLOBALS['_wp_cache'] );
}
public function test_prefetch_skips_already_cached_post_ids(): void {
$this->register_cold_field();
$group = 'wpdo_cold_hp_listing';
// Pre-warm cache for post 1.
$GLOBALS['_wp_cache'][ $group ]['cold_1'] = [ 'hp_description' => 'cached' ];
// DB returns no extra rows — all were already cached.
self::$db_rows = [];
WPDO_Cache_Layer::prefetch( [ 1 ], 'hp_listing' );
// Cache should remain unchanged (no new entry written).
$this->assertSame( [ 'hp_description' => 'cached' ], $GLOBALS['_wp_cache'][ $group ]['cold_1'] );
}
public function test_prefetch_stores_fetched_data_in_cache(): void {
$this->register_cold_field();
self::$db_rows = [
[ 'post_id' => '5', 'data' => json_encode( [ 'hp_description' => 'Fetched!' ] ) ],
];
WPDO_Cache_Layer::prefetch( [ 5 ], 'hp_listing' );
$group = 'wpdo_cold_hp_listing';
$cached = $GLOBALS['_wp_cache'][ $group ]['cold_5'] ?? false;
$this->assertIsArray( $cached );
$this->assertSame( 'Fetched!', $cached['hp_description'] );
}
public function test_prefetch_stores_empty_array_for_post_with_no_cold_row(): void {
$this->register_cold_field();
// DB returns nothing for post 7.
self::$db_rows = [];
WPDO_Cache_Layer::prefetch( [ 7 ], 'hp_listing' );
$group = 'wpdo_cold_hp_listing';
$cached = $GLOBALS['_wp_cache'][ $group ]['cold_7'] ?? 'NOT_SET';
$this->assertSame( [], $cached );
}
// ── warm_post() ──────────────────────────────────────────────────────────
public function test_warm_post_returns_early_when_no_cold_keys(): void {
// 'no_cold_type' has no cold fields → warm_post returns immediately.
WPDO_Cache_Layer::warm_post( 1, 'no_cold_type' );
$this->assertEmpty( $GLOBALS['_wp_cache'] );
}
public function test_warm_post_clears_stale_cache_entry(): void {
$this->register_cold_field();
$group = 'wpdo_cold_hp_listing';
$cache_key = 'cold_20';
// Pre-populate with stale data.
$GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'stale' => true ];
WPDO_Cache_Layer::warm_post( 20, 'hp_listing' );
// Stale cache must be replaced (warm_post deletes then re-reads from DB,
// which returns null in mock, yielding empty array).
$this->assertNotSame( [ 'stale' => true ], $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ?? null );
}
// ── get_stats() ──────────────────────────────────────────────────────────
public function test_get_stats_includes_required_keys(): void {
$stats = WPDO_Cache_Layer::get_stats();
$this->assertArrayHasKey( 'groups', $stats );
$this->assertArrayHasKey( 'prefetch_support', $stats );
$this->assertArrayHasKey( 'flush_support', $stats );
}
public function test_get_stats_groups_reflect_registered_cold_types(): void {
$this->register_cold_field( 'hp_listing', 'hp_description' );
$this->register_cold_field( 'hp_listing', 'hp_website' );
$stats = WPDO_Cache_Layer::get_stats();
$post_types = array_column( $stats['groups'], 'post_type' );
$this->assertContains( 'hp_listing', $post_types );
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit test: WPDO_Capability (v2.14.0).
*
* Verifies the multisite-aware admin capability gate:
* - Single-site: only `manage_options` decides
* - Multisite per-site: only `manage_options` decides
* - Multisite network: super admin always passes; site admin still passes
* on their own site if they hold `manage_options`
*/
class CapabilityTest extends TestCase {
protected function setUp(): void {
// Reset all multisite globals before each test.
unset(
$GLOBALS['_wp_is_multisite'],
$GLOBALS['_wp_is_super_admin'],
$GLOBALS['_wp_current_user_can']
);
}
// ── Single-site behaviour ────────────────────────────────────────────────
public function test_single_site_admin_passes(): void {
$GLOBALS['_wp_is_multisite'] = false;
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
$this->assertTrue( WPDO_Capability::current_user_can_admin() );
}
public function test_single_site_subscriber_blocked(): void {
$GLOBALS['_wp_is_multisite'] = false;
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false );
$this->assertFalse( WPDO_Capability::current_user_can_admin() );
}
// ── Multisite per-site behaviour ─────────────────────────────────────────
public function test_multisite_site_admin_passes_with_manage_options(): void {
$GLOBALS['_wp_is_multisite'] = true;
$GLOBALS['_wp_is_super_admin'] = false;
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
$this->assertTrue( WPDO_Capability::current_user_can_admin() );
}
public function test_multisite_subscriber_blocked(): void {
$GLOBALS['_wp_is_multisite'] = true;
$GLOBALS['_wp_is_super_admin'] = false;
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false );
$this->assertFalse( WPDO_Capability::current_user_can_admin() );
}
// ── Multisite super-admin behaviour ──────────────────────────────────────
public function test_multisite_super_admin_passes_without_manage_options(): void {
// Pre-v2.14.0 this returned false because super admins don't auto-have
// `manage_options` in network admin context. v2.14.0 fixes this.
$GLOBALS['_wp_is_multisite'] = true;
$GLOBALS['_wp_is_super_admin'] = true;
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false );
$this->assertTrue( WPDO_Capability::current_user_can_admin() );
}
public function test_multisite_super_admin_passes_with_manage_options(): void {
$GLOBALS['_wp_is_multisite'] = true;
$GLOBALS['_wp_is_super_admin'] = true;
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
$this->assertTrue( WPDO_Capability::current_user_can_admin() );
}
// ── Edge: super admin flag set on single-site (shouldn't be possible
// but should be defensive) ────────────────────────────────────────
public function test_super_admin_flag_ignored_on_single_site(): void {
// Super admin only exists on multisite; on single-site fall back to
// manage_options check.
$GLOBALS['_wp_is_multisite'] = false;
$GLOBALS['_wp_is_super_admin'] = true;
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false );
$this->assertFalse( WPDO_Capability::current_user_can_admin() );
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_CLI_V2::lint_directory() — Anti-EAV strict lint (PR-6).
*
* @covers WPDO_CLI_V2::lint_directory
*/
class CliLintTest extends TestCase {
private string $fixture_dir;
public static function setUpBeforeClass(): void {
// Stub WP_CLI just enough so the cli-v2 file can be require'd without errors.
if ( ! class_exists( 'WP_CLI' ) ) {
eval( 'class WP_CLI { public static function add_command( $name, $callable ) {} public static function log( $msg ) {} public static function warning( $msg ) {} public static function error( $msg ) { throw new \RuntimeException( $msg ); } public static function success( $msg ) {} }' ); // phpcs:ignore Squiz.PHP.Eval -- test-only stub.
}
if ( ! class_exists( 'TMDO_CLI_V2' ) ) {
require_once dirname( __DIR__, 2 ) . '/cli/class-tmdo-cli-v2.php';
}
}
protected function setUp(): void {
$this->fixture_dir = sys_get_temp_dir() . '/wpdo-lint-fixture-' . uniqid();
mkdir( $this->fixture_dir, 0777, true );
}
protected function tearDown(): void {
// Recursive rmdir.
$this->rrmdir( $this->fixture_dir );
}
private function rrmdir( string $dir ): void {
if ( ! is_dir( $dir ) ) {
return;
}
foreach ( scandir( $dir ) as $f ) {
if ( '.' === $f || '..' === $f ) {
continue;
}
$path = $dir . '/' . $f;
is_dir( $path ) ? $this->rrmdir( $path ) : unlink( $path );
}
rmdir( $dir );
}
private function write( string $relative, string $content ): void {
$path = $this->fixture_dir . '/' . $relative;
$dir = dirname( $path );
if ( ! is_dir( $dir ) ) {
mkdir( $dir, 0777, true );
}
file_put_contents( $path, $content );
}
// ── happy path ──────────────────────────────────────────────────────────
public function test_clean_plugin_passes_lint(): void {
$this->write( 'main.php', "<?php\nfunction foo() { return 'bar'; }\n" );
$findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
$this->assertEmpty( $findings );
}
// ── direct postmeta SELECT ─────────────────────────────────────────────
public function test_direct_postmeta_select_flagged(): void {
$this->write(
'bad.php',
"<?php\n\$rows = \$wpdb->get_results( \"SELECT meta_value FROM {\$wpdb->prefix}postmeta WHERE meta_key='foo'\" );\n"
);
$findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
$this->assertGreaterThanOrEqual( 1, count( $findings ) );
$this->assertSame( 'no-direct-postmeta-select', $findings[0]['rule'] );
}
public function test_direct_usermeta_select_flagged(): void {
$this->write(
'user.php',
"<?php\n\$wpdb->get_var( \"SELECT meta_value FROM wp_usermeta WHERE meta_key='foo'\" );\n"
);
$findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
$this->assertGreaterThanOrEqual( 1, count( $findings ) );
$this->assertSame( 'no-direct-usermeta-select', $findings[0]['rule'] );
}
// ── autoload=yes ───────────────────────────────────────────────────────
public function test_autoload_yes_flagged(): void {
$this->write(
'opt.php',
"<?php\nadd_option( 'mykey', 'val', '', 'yes' );\n\$x = array( 'autoload' => 'yes' );\n"
);
$findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
$autoload_findings = array_filter( $findings, static fn( $f ) => 'autoload-yes' === $f['rule'] );
$this->assertGreaterThanOrEqual( 1, count( $autoload_findings ) );
}
// ── ignore comment ─────────────────────────────────────────────────────
public function test_phpcs_ignore_comment_skips_finding(): void {
$this->write(
'fallback.php',
"<?php\n// phpcs:ignore WPDO.AntiEAV.PostmetaFallback -- legacy fallback path\n\$rows = \$wpdb->get_results( \"SELECT meta_value FROM wp_postmeta WHERE meta_key='x'\" );\n"
);
$findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
$this->assertEmpty( $findings, 'phpcs:ignore WPDO.AntiEAV must suppress findings' );
}
// ── skip dirs ──────────────────────────────────────────────────────────
public function test_skips_vendor_and_node_modules(): void {
// Bad code inside vendor/ MUST be ignored.
$this->write(
'vendor/lib/bad.php',
"<?php\n\$wpdb->get_results( \"SELECT meta_value FROM wp_postmeta\" );\n"
);
$this->write(
'node_modules/foo/bad.php',
"<?php\n\$wpdb->get_results( \"SELECT meta_value FROM wp_usermeta\" );\n"
);
$findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
$this->assertEmpty( $findings );
}
// ── reports file + line ────────────────────────────────────────────────
public function test_finding_includes_file_and_line(): void {
$this->write(
'multi.php',
"<?php\n\n\$x = 1;\n\$wpdb->get_var( \"SELECT meta_value FROM wp_postmeta\" );\n"
);
$findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir );
$this->assertNotEmpty( $findings );
$this->assertStringEndsWith( 'multi.php', $findings[0]['file'] );
$this->assertSame( 4, $findings[0]['line'] );
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Conflict_Monitor — production conflict surface.
*
* @covers WPDO_Conflict_Monitor
*/
class ConflictMonitorTest extends TestCase {
protected function setUp(): void {
WPDO_Conflict_Monitor::reset_cache();
WPDO_Hook_Bus_Bridge::reset_cache();
$GLOBALS['_wp_options'] = array();
$GLOBALS['wp_filter'] = array();
}
public function test_scan_returns_empty_when_no_conflicts(): void {
$result = WPDO_Conflict_Monitor::scan();
$this->assertIsArray( $result );
$this->assertEmpty( $result );
}
public function test_get_summary_when_clean(): void {
$summary = WPDO_Conflict_Monitor::get_summary();
$this->assertSame( 0, $summary['total'] );
$this->assertSame( 0, $summary['hook_overlap'] );
$this->assertSame( 0, $summary['uaepg_overlap'] );
}
public function test_get_all_conflicts_lazy_scans(): void {
// First call populates the cache.
$first = WPDO_Conflict_Monitor::get_all_conflicts();
$this->assertIsArray( $first );
// Subsequent calls return the same reference (cached).
$second = WPDO_Conflict_Monitor::get_all_conflicts();
$this->assertSame( $first, $second );
}
public function test_reset_cache_forces_rescan(): void {
WPDO_Conflict_Monitor::scan();
WPDO_Conflict_Monitor::reset_cache();
// Should not throw and should return empty (still no conflicts).
$this->assertSame( array(), WPDO_Conflict_Monitor::scan() );
}
public function test_summary_keys_always_present(): void {
$summary = WPDO_Conflict_Monitor::get_summary();
$this->assertArrayHasKey( 'total', $summary );
$this->assertArrayHasKey( 'hook_overlap', $summary );
$this->assertArrayHasKey( 'uaepg_overlap', $summary );
}
public function test_admin_notice_is_silent_when_no_conflicts(): void {
ob_start();
WPDO_Conflict_Monitor::maybe_render_admin_notice();
$output = ob_get_clean();
$this->assertSame( '', $output );
}
public function test_admin_bar_is_silent_when_no_conflicts(): void {
// Pass a valid object stub to admin_bar handler — should no-op when count = 0.
$stub = new class() {
public array $nodes = array();
public function add_node( array $node ): void {
$this->nodes[] = $node;
}
};
WPDO_Conflict_Monitor::maybe_render_admin_bar( $stub );
$this->assertCount( 0, $stub->nodes );
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit test: WPDO_Crypto AES-256-GCM v2 + AES-256-CBC v1 backward compat (v2.15.0).
*
* Verifies:
* - v2 GCM round-trip (encrypt/decrypt)
* - v2 GCM tamper detection (auth tag verification)
* - v1 CBC backward compat read
* - Plaintext passthrough
* - Empty input handling
* - Invalid input safe failure
*/
class CryptoV2Test extends TestCase {
public static function setUpBeforeClass(): void {
// Define WP auth constants for stable key derivation in tests.
if ( ! defined( 'AUTH_KEY' ) ) {
define( 'AUTH_KEY', 'test_auth_key_for_phpunit_long_enough_string_xxxxxxxxxxxxxxxx' );
}
if ( ! defined( 'SECURE_AUTH_SALT' ) ) {
define( 'SECURE_AUTH_SALT', 'test_secure_auth_salt_for_phpunit_xxxxxxxxxxxxxxxxxxxxxxx' );
}
}
// ── v2 GCM happy paths ───────────────────────────────────────────────────
public function test_v2_round_trip_simple_string(): void {
$plain = 'https://hooks.slack.com/services/T00000000/B00000000/abc123';
$encrypted = WPDO_Crypto::encrypt( $plain );
$this->assertStringStartsWith( WPDO_Crypto::PREFIX_V2, $encrypted );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) );
}
public function test_v2_round_trip_unicode(): void {
$plain = '中文密碼 + emoji 🔐 + special chars !@#$%^&*()';
$encrypted = WPDO_Crypto::encrypt( $plain );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) );
}
public function test_v2_round_trip_long_string(): void {
$plain = str_repeat( 'A', 4096 );
$encrypted = WPDO_Crypto::encrypt( $plain );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) );
}
public function test_v2_each_encryption_produces_unique_ciphertext(): void {
// Random IV → repeated encrypts of the same plaintext yield different blobs.
$plain = 'identical plaintext';
$ct1 = WPDO_Crypto::encrypt( $plain );
$ct2 = WPDO_Crypto::encrypt( $plain );
$this->assertNotSame( $ct1, $ct2, 'IV randomness should produce unique ciphertexts' );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $ct1 ) );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $ct2 ) );
}
// ── v2 GCM tamper detection ──────────────────────────────────────────────
public function test_v2_tampered_ciphertext_returns_original(): void {
$plain = 'sensitive webhook url';
$encrypted = WPDO_Crypto::encrypt( $plain );
// Decode the base64 payload, flip the FIRST byte of the GCM auth tag
// (which lives at offset 12 right after the IV), re-encode. This
// guarantees a real ciphertext modification regardless of base64
// alphabet (vs str_replace which can be a no-op for some random IVs).
$prefix_len = strlen( WPDO_Crypto::PREFIX_V2 );
$encoded = substr( $encrypted, $prefix_len );
$raw = base64_decode( $encoded, true );
$this->assertNotFalse( $raw, 'Setup precondition: ciphertext must be valid base64' );
$raw[12] = chr( ord( $raw[12] ) ^ 0x55 ); // flip 4 bits of the auth tag.
$tampered = WPDO_Crypto::PREFIX_V2 . base64_encode( $raw );
$result = WPDO_Crypto::decrypt( $tampered );
$this->assertNotSame( $plain, $result, 'Tampered GCM ciphertext must NOT decrypt to original plaintext' );
$this->assertSame( $tampered, $result, 'On auth failure decrypt() must return original blob' );
}
public function test_v2_truncated_blob_safe_failure(): void {
$encrypted = WPDO_Crypto::encrypt( 'some value' );
// Truncate to less than min size (12 IV + 16 tag + 1 byte ciphertext).
$truncated = substr( $encrypted, 0, strlen( WPDO_Crypto::PREFIX_V2 ) + 5 );
// Should not throw; should return original.
$result = WPDO_Crypto::decrypt( $truncated );
$this->assertSame( $truncated, $result );
}
// ── v1 CBC backward compat ───────────────────────────────────────────────
public function test_v1_legacy_blob_decrypts_successfully(): void {
// Hand-craft a v1 CBC blob using the same key derivation.
$plain = 'legacy webhook url from pre-v2.15';
$key = $this->derive_key();
$iv = random_bytes( 16 );
$ct = openssl_encrypt( $plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
$blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
$this->assertSame( $plain, WPDO_Crypto::decrypt( $blob ) );
}
public function test_v1_blob_with_garbage_returns_original(): void {
$bad = WPDO_Crypto::PREFIX_V1 . 'not_valid_base64!!!';
$this->assertSame( $bad, WPDO_Crypto::decrypt( $bad ) );
}
// ── Plaintext passthrough ────────────────────────────────────────────────
public function test_plaintext_passthrough(): void {
$plain = 'https://example.com/raw';
$this->assertSame( $plain, WPDO_Crypto::decrypt( $plain ) );
}
public function test_empty_input(): void {
$this->assertSame( '', WPDO_Crypto::encrypt( '' ) );
$this->assertSame( '', WPDO_Crypto::decrypt( '' ) );
}
// ── format_version ───────────────────────────────────────────────────────
public function test_format_version_classification(): void {
// Use option-API stubs from bootstrap.
$GLOBALS['_wp_options']['test_v2_opt'] = WPDO_Crypto::encrypt( 'foo' );
$GLOBALS['_wp_options']['test_plain_opt'] = 'plaintext_value';
$GLOBALS['_wp_options']['test_empty_opt'] = '';
// Hand-craft a v1 blob.
$key = $this->derive_key();
$iv = random_bytes( 16 );
$ct = openssl_encrypt( 'bar', 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
$GLOBALS['_wp_options']['test_v1_opt'] = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
$this->assertSame( 'v2', WPDO_Crypto::format_version( 'test_v2_opt' ) );
$this->assertSame( 'v1', WPDO_Crypto::format_version( 'test_v1_opt' ) );
$this->assertSame( 'plaintext', WPDO_Crypto::format_version( 'test_plain_opt' ) );
$this->assertSame( 'empty', WPDO_Crypto::format_version( 'test_empty_opt' ) );
$this->assertSame( 'empty', WPDO_Crypto::format_version( 'nonexistent_opt' ) );
}
// ── migrate_option_v1_to_v2 ──────────────────────────────────────────────
public function test_migrate_option_v1_to_v2_round_trip(): void {
$plain = 'webhook to migrate';
$key = $this->derive_key();
$iv = random_bytes( 16 );
$ct = openssl_encrypt( $plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
$blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct );
$GLOBALS['_wp_options']['migrate_test'] = $blob;
$result = WPDO_Crypto::migrate_option_v1_to_v2( 'migrate_test' );
$this->assertSame( 'migrated', $result );
// After migration: v2 blob, decrypts to original plaintext.
$this->assertSame( 'v2', WPDO_Crypto::format_version( 'migrate_test' ) );
$this->assertSame( $plain, WPDO_Crypto::get_option( 'migrate_test' ) );
}
public function test_migrate_option_already_v2_is_noop(): void {
$GLOBALS['_wp_options']['already_v2'] = WPDO_Crypto::encrypt( 'foo' );
$result = WPDO_Crypto::migrate_option_v1_to_v2( 'already_v2' );
$this->assertSame( 'already_v2', $result );
}
public function test_migrate_option_plaintext_skipped(): void {
$GLOBALS['_wp_options']['plain_opt'] = 'just plaintext';
$result = WPDO_Crypto::migrate_option_v1_to_v2( 'plain_opt' );
$this->assertSame( 'plaintext_skipped', $result );
// Original value preserved.
$this->assertSame( 'just plaintext', $GLOBALS['_wp_options']['plain_opt'] );
}
public function test_migrate_option_empty_returns_empty(): void {
$GLOBALS['_wp_options']['empty_opt'] = '';
$result = WPDO_Crypto::migrate_option_v1_to_v2( 'empty_opt' );
$this->assertSame( 'empty', $result );
}
// ── Helper ───────────────────────────────────────────────────────────────
/**
* Replicates WPDO_Crypto::derived_key() to craft test fixtures.
*
* @return string 32 raw bytes.
*/
private function derive_key(): string {
$salt = AUTH_KEY . SECURE_AUTH_SALT;
return substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', $salt, true ), 0, 32 );
}
}
+261
View File
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Custom_Table_Registry — partner plugin custom table awareness.
*
* Solves audit finding R-3 (Custom Table Provider missing).
*
* @covers WPDO_Custom_Table_Registry
*/
class CustomTableRegistryTest extends TestCase {
protected function setUp(): void {
WPDO_Custom_Table_Registry::reset_for_tests();
}
// ── register() ──────────────────────────────────────────────────────────
public function test_register_single_table(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$ok = $registry->register( '2meet-courses', array(
'table_name' => '2mc_courses',
'primary_key' => 'id',
'post_type_link' => null,
) );
$this->assertTrue( $ok );
$this->assertCount( 1, $registry->all() );
}
public function test_register_rejects_empty_table_name(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$this->assertFalse( $registry->register( '2meet-courses', array() ) );
$this->assertFalse( $registry->register( '2meet-courses', array( 'table_name' => '' ) ) );
}
public function test_register_rejects_empty_provider(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$this->assertFalse( $registry->register( '', array( 'table_name' => '2mc_courses' ) ) );
}
public function test_register_rejects_duplicate_provider_table_pair(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$this->assertTrue( $registry->register( '2meet-courses', array( 'table_name' => '2mc_courses' ) ) );
// Same provider+table → false.
$this->assertFalse( $registry->register( '2meet-courses', array( 'table_name' => '2mc_courses' ) ) );
}
public function test_register_allows_same_table_different_provider(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$this->assertTrue( $registry->register( '2meet-courses', array( 'table_name' => 'shared_t' ) ) );
$this->assertTrue( $registry->register( '2meet-bookings', array( 'table_name' => 'shared_t' ) ) );
$this->assertCount( 2, $registry->all() );
}
public function test_register_sanitizes_table_name(): void {
$registry = WPDO_Custom_Table_Registry::instance();
// WordPress sanitize_key strips non-alphanumeric/underscore/dash entirely (no replacement).
$registry->register( 'p', array( 'table_name' => 'My Bad-Name!' ) );
$tables = $registry->all();
$cfg = reset( $tables );
$this->assertSame( 'mybad-name', $cfg['table_name'] );
}
public function test_register_applies_defaults(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'p', array( 'table_name' => 't' ) );
$tables = $registry->all();
$cfg = reset( $tables );
$this->assertSame( 'id', $cfg['primary_key'] );
$this->assertNull( $cfg['post_type_link'] );
$this->assertSame( array(), $cfg['expected_columns'] );
}
// ── unregister() ────────────────────────────────────────────────────────
public function test_unregister_removes_table(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'p', array( 'table_name' => 't' ) );
$this->assertTrue( $registry->unregister( 'p', 't' ) );
$this->assertCount( 0, $registry->all() );
}
public function test_unregister_returns_false_for_unknown(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$this->assertFalse( $registry->unregister( 'unknown', 'table' ) );
}
// ── for_provider() / for_post_type() ────────────────────────────────────
public function test_for_provider_filters_correctly(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'a', array( 'table_name' => 't1' ) );
$registry->register( 'a', array( 'table_name' => 't2' ) );
$registry->register( 'b', array( 'table_name' => 't3' ) );
$this->assertCount( 2, $registry->for_provider( 'a' ) );
$this->assertCount( 1, $registry->for_provider( 'b' ) );
$this->assertCount( 0, $registry->for_provider( 'c' ) );
}
/**
* v2.1.3 R3 hardening — verify by_provider index stays in sync when a
* non-edge entry is unregistered. Previously untested per audit finding.
*/
public function test_for_provider_after_unregister_middle_entry(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'p', array( 'table_name' => 'first' ) );
$registry->register( 'p', array( 'table_name' => 'middle' ) );
$registry->register( 'p', array( 'table_name' => 'last' ) );
$this->assertCount( 3, $registry->for_provider( 'p' ) );
// Remove the middle entry.
$ok = $registry->unregister( 'p', 'middle' );
$this->assertTrue( $ok );
$remaining = $registry->for_provider( 'p' );
$this->assertCount( 2, $remaining );
// Verify the surviving entries are correct (not 'middle').
$names = array_column( $remaining, 'table_name' );
sort( $names );
$this->assertSame( array( 'first', 'last' ), $names );
// Re-registering 'middle' should put it back.
$registry->register( 'p', array( 'table_name' => 'middle' ) );
$this->assertCount( 3, $registry->for_provider( 'p' ) );
}
/**
* Verify by_provider index empties (and removes the provider key entirely)
* when the last table for that provider is unregistered.
*/
public function test_for_provider_returns_empty_after_full_unregister(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'solo', array( 'table_name' => 'only_table' ) );
$this->assertCount( 1, $registry->for_provider( 'solo' ) );
$registry->unregister( 'solo', 'only_table' );
$this->assertCount( 0, $registry->for_provider( 'solo' ) );
$this->assertSame( array(), $registry->for_provider( 'solo' ) );
}
public function test_for_post_type_filters_correctly(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'p', array( 'table_name' => 't1', 'post_type_link' => 'hp_listing' ) );
$registry->register( 'p', array( 'table_name' => 't2', 'post_type_link' => 'hp_listing' ) );
$registry->register( 'p', array( 'table_name' => 't3', 'post_type_link' => 'hp_vendor' ) );
$registry->register( 'p', array( 'table_name' => 't4', 'post_type_link' => null ) );
$this->assertCount( 2, $registry->for_post_type( 'hp_listing' ) );
$this->assertCount( 1, $registry->for_post_type( 'hp_vendor' ) );
$this->assertCount( 0, $registry->for_post_type( 'unknown' ) );
}
public function test_providers_returns_unique_list(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'a', array( 'table_name' => 't1' ) );
$registry->register( 'a', array( 'table_name' => 't2' ) );
$registry->register( 'b', array( 'table_name' => 't3' ) );
$providers = $registry->providers();
sort( $providers );
$this->assertSame( array( 'a', 'b' ), $providers );
}
// ── get_stats() ─────────────────────────────────────────────────────────
public function test_get_stats_counts_callbacks(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'p', array(
'table_name' => 't1',
'doctor_callback' => static fn() => array( 'ok' => true ),
'benchmark_callback' => static fn() => array( 'duration_ms' => 1.0 ),
) );
$registry->register( 'p', array( 'table_name' => 't2' ) );
$stats = $registry->get_stats();
$this->assertSame( 2, $stats['tables_count'] );
$this->assertSame( 1, $stats['providers_count'] );
$this->assertSame( 1, $stats['with_doctor'] );
$this->assertSame( 1, $stats['with_benchmark'] );
}
// ── run_doctor_checks() ─────────────────────────────────────────────────
public function test_run_doctor_checks_invokes_callbacks(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'p', array(
'table_name' => 't1',
'doctor_callback' => static fn() => array( 'ok' => true, 'message' => 'all good' ),
) );
$registry->register( 'p', array(
'table_name' => 't2',
'doctor_callback' => static fn() => array( 'ok' => false, 'message' => 'index missing' ),
) );
$registry->register( 'p', array( 'table_name' => 't3' ) ); // No callback → skipped.
$results = $registry->run_doctor_checks();
$this->assertCount( 2, $results );
$this->assertTrue( $results['p:t1']['ok'] );
$this->assertSame( 'all good', $results['p:t1']['message'] );
$this->assertFalse( $results['p:t2']['ok'] );
}
public function test_run_doctor_checks_passes_table_name_to_callback(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$received = null;
$registry->register( 'wc', array(
'table_name' => 'wc_orders',
'doctor_callback' => static function ( string $table_name ) use ( &$received ): array {
$received = $table_name;
return array( 'ok' => true, 'message' => "checked {$table_name}" );
},
) );
$results = $registry->run_doctor_checks();
$this->assertSame( 'wc_orders', $received, 'callback must receive table_name as first argument' );
$this->assertSame( 'checked wc_orders', $results['wc:wc_orders']['message'] );
}
public function test_run_doctor_checks_catches_throwables(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$registry->register( 'p', array(
'table_name' => 't1',
'doctor_callback' => static function () {
throw new RuntimeException( 'simulated failure' );
},
) );
$results = $registry->run_doctor_checks();
$this->assertCount( 1, $results );
$this->assertFalse( $results['p:t1']['ok'] );
$this->assertStringContainsString( 'simulated failure', $results['p:t1']['message'] );
}
// ── fire_registration() idempotency ─────────────────────────────────────
public function test_fire_registration_is_idempotent(): void {
$count = 0;
add_action( 'wpdo_register_custom_tables', function () use ( &$count ) {
++$count;
} );
$registry = WPDO_Custom_Table_Registry::instance();
$registry->fire_registration();
$registry->fire_registration();
$registry->fire_registration();
// Stub add_action() in unit bootstrap returns true but does NOT execute callbacks,
// so the meaningful assertion here is that fire_registration() doesn't throw.
$this->assertTrue( true );
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
if ( ! function_exists( 'wp_strip_all_tags' ) ) {
function wp_strip_all_tags( string $s ): string {
return strip_tags( $s );
}
}
if ( ! function_exists( 'delete_option' ) ) {
function delete_option( string $key ): bool {
unset( $GLOBALS['_wp_options'][ $key ] );
return true;
}
}
if ( ! function_exists( 'do_action' ) ) {
function do_action( string $hook, ...$args ): void {
// no-op for unit tests.
}
}
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
public string $code;
public string $message;
public array $data;
public function __construct( string $code = '', string $message = '', $data = array() ) {
$this->code = $code;
$this->message = $message;
$this->data = (array) $data;
}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
public function get_error_data() { return $this->data; }
}
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/diagnostic/class-tmdo-health-cron.php';
/**
* Unit tests for WPDO_Health_Cron (v2.3.0 M6).
*
* Pure-logic tests — does not exercise actual cron firing or full Site Health
* subprocess (those covered by integration suite). Asserts on output shape +
* counter aggregation + alert flag setting.
*/
class HealthCronTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public string $options = 'wp_options';
// v2.5.0Module_Detector reads $wpdb->posts during Health_Cron
// integration; declared here to silence undefined-property warning.
public string $posts = 'wp_posts';
public string $postmeta = 'wp_postmeta';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%[sd]/', function() use ( &$i, $args ) {
return (string) ( $args[ $i++ ] ?? '?' );
}, $sql );
}
public function get_var( string $sql ) {
$upper = strtoupper( $sql );
// Table-existence probes → truthy so schema_drift / orphan_zone passes.
if ( str_contains( $upper, 'SHOW TABLES' ) || str_contains( $upper, 'INFORMATION_SCHEMA' ) ) {
return '1';
}
return '0';
}
public function get_results( string $sql, $output = ARRAY_A ): array {
return array();
}
public function query( string $sql ): int { return 0; }
public function insert( string $table, array $data ): int { return 1; }
};
}
public function test_get_last_run_returns_null_when_never_run(): void {
$this->assertNull( WPDO_Health_Cron::get_last_run() );
}
public function test_run_returns_summary_shape(): void {
$result = WPDO_Health_Cron::run();
$this->assertTrue( $result['ok'] );
$this->assertArrayHasKey( 'summary', $result );
$this->assertArrayHasKey( 'critical_count', $result );
$this->assertArrayHasKey( 'recommended_count', $result );
$this->assertArrayHasKey( 'ts', $result );
$this->assertArrayHasKey( 'tests', $result['summary'] );
$this->assertArrayHasKey( 'conflicts', $result['summary'] );
$this->assertArrayHasKey( 'shadow_diffs', $result['summary'] );
$this->assertArrayHasKey( 'autoload_bytes', $result['summary'] );
$this->assertArrayHasKey( 'duration_ms', $result['summary'] );
}
public function test_run_persists_last_run_option(): void {
WPDO_Health_Cron::run();
$last = WPDO_Health_Cron::get_last_run();
$this->assertIsArray( $last );
$this->assertArrayHasKey( 'tests', $last );
$this->assertArrayHasKey( 'critical_count', $last );
}
public function test_run_clears_alert_when_no_critical(): void {
// Pre-set an alert.
update_option( WPDO_Health_Cron::OPTION_ALERT, array( 'level' => 'critical' ), false );
WPDO_Health_Cron::run();
$this->assertFalse( get_option( WPDO_Health_Cron::OPTION_ALERT ), 'alert should be cleared on green run' );
}
public function test_consecutive_green_days_zero_when_no_run(): void {
$this->assertSame( 0, WPDO_Health_Cron::consecutive_green_days() );
}
public function test_consecutive_green_days_one_after_green_run(): void {
WPDO_Health_Cron::run();
$this->assertSame( 1, WPDO_Health_Cron::consecutive_green_days() );
}
public function test_option_keys_are_documented(): void {
$this->assertSame( 'wpdo_health_last_run', WPDO_Health_Cron::OPTION_LAST_RUN );
$this->assertSame( 'wpdo_health_alert', WPDO_Health_Cron::OPTION_ALERT );
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require_once dirname( __DIR__, 3 ) . '/includes/export/class-tmdo-csv-writer.php';
/**
* Unit tests for WPDO_CSV_Writer (v2.5.0 M14).
*/
class CsvWriterTest extends TestCase {
public function test_starts_with_utf8_bom(): void {
$out = WPDO_CSV_Writer::build( array( 'a', 'b' ), array() );
$this->assertSame( "\xEF\xBB\xBF", substr( $out, 0, 3 ) );
}
public function test_simple_row_csv_output(): void {
$out = WPDO_CSV_Writer::build( array( 'a', 'b' ), array( array( 'a' => '1', 'b' => '2' ) ) );
$this->assertStringContainsString( "a,b\r\n1,2\r\n", $out );
}
public function test_field_with_comma_gets_quoted(): void {
$out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => 'foo,bar' ) ) );
$this->assertStringContainsString( '"foo,bar"', $out );
}
public function test_field_with_quote_doubles_it(): void {
$out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => 'say "hi"' ) ) );
$this->assertStringContainsString( '"say ""hi"""', $out );
}
public function test_field_with_newline_gets_quoted(): void {
$out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => "line1\nline2" ) ) );
$this->assertStringContainsString( "\"line1\nline2\"", $out );
}
public function test_array_value_serializes_to_json(): void {
$out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => array( 'a', 'b' ) ) ) );
// Array becomes JSON; quotes inside JSON are doubled inside the CSV-quoted field.
$this->assertStringContainsString( '"[""a"",""b""]"', $out );
}
public function test_missing_field_renders_empty(): void {
$out = WPDO_CSV_Writer::build( array( 'a', 'b' ), array( array( 'a' => '1' ) ) );
$this->assertStringContainsString( "1,\r\n", $out );
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Feature_Flags — 7-state lifecycle state machine.
*/
class FeatureFlagsTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = [];
}
// ── State retrieval ──────────────────────────────────────────────────────
public function test_unregistered_module_returns_idle(): void {
$this->assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_unknown' ) );
}
public function test_get_returns_stored_state(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
$this->assertSame( 'backfill', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
}
// ── set() ─────────────────────────────────────────────────────────────────
public function test_set_valid_state_returns_true(): void {
$result = WPDO_Feature_Flags::set( 'mod', 'cutover' );
$this->assertTrue( $result );
$this->assertSame( 'cutover', WPDO_Feature_Flags::get( 'mod' ) );
}
public function test_set_invalid_state_returns_false(): void {
$result = WPDO_Feature_Flags::set( 'mod', 'invalid_state_xyz' );
$this->assertFalse( $result );
}
// ── Query-active check ──────────────────────────────────────────────────
public function test_is_query_active_true_when_cutover(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
$this->assertTrue( WPDO_Feature_Flags::is_query_active( 'hot_hp_listing' ) );
}
public function test_is_query_active_true_when_complete(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' );
$this->assertTrue( WPDO_Feature_Flags::is_query_active( 'hot_hp_listing' ) );
}
public function test_is_query_active_false_when_backfill(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
$this->assertFalse( WPDO_Feature_Flags::is_query_active( 'hot_hp_listing' ) );
}
// ── is_write_active ──────────────────────────────────────────────────────
public function test_is_write_active_true_when_dual_write(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' );
$this->assertTrue( WPDO_Feature_Flags::is_write_active( 'hot_hp_listing' ) );
}
public function test_is_write_active_false_when_idle(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
$this->assertFalse( WPDO_Feature_Flags::is_write_active( 'hot_hp_listing' ) );
}
// ── is_read_custom ───────────────────────────────────────────────────────
public function test_is_read_custom_true_when_cutover(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
$this->assertTrue( WPDO_Feature_Flags::is_read_custom( 'hot_hp_listing' ) );
}
public function test_is_read_custom_false_when_backfill(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
$this->assertFalse( WPDO_Feature_Flags::is_read_custom( 'hot_hp_listing' ) );
}
// ── all() ────────────────────────────────────────────────────────────────
public function test_all_returns_array_of_states(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
WPDO_Feature_Flags::set( 'hot_hp_vendor', 'idle' );
$all = WPDO_Feature_Flags::all();
$this->assertIsArray( $all );
$this->assertSame( 'cutover', $all['hot_hp_listing'] );
$this->assertSame( 'idle', $all['hot_hp_vendor'] );
}
// ── reset() ─────────────────────────────────────────────────────────────
public function test_reset_returns_module_to_idle(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' );
WPDO_Feature_Flags::reset( 'hot_hp_listing' );
$this->assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
}
// ── is_complete ──────────────────────────────────────────────────────────
public function test_is_complete_true_when_complete(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' );
$this->assertTrue( WPDO_Feature_Flags::is_complete( 'hot_hp_listing' ) );
}
public function test_is_complete_false_when_cutover(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
$this->assertFalse( WPDO_Feature_Flags::is_complete( 'hot_hp_listing' ) );
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Hook_Bus_Bridge — PR-3 feature-flag + conflict detection.
*
* @covers WPDO_Hook_Bus_Bridge
*/
class HookBusBridgeTest extends TestCase {
protected function setUp(): void {
WPDO_Hook_Bus_Bridge::reset_cache();
// Reset $GLOBALS['_wp_options'] for each test isolation.
$GLOBALS['_wp_options'] = array();
}
// ── is_enabled() ────────────────────────────────────────────────────────
public function test_is_enabled_default_true(): void {
// v2.5.4: Hook Bus is ON by default; option not set → true.
$this->assertTrue( WPDO_Hook_Bus_Bridge::is_enabled() );
}
public function test_is_enabled_when_option_set_to_string_one(): void {
update_option( WPDO_Hook_Bus_Bridge::OPTION, '1' );
WPDO_Hook_Bus_Bridge::reset_cache();
$this->assertTrue( WPDO_Hook_Bus_Bridge::is_enabled() );
}
public function test_is_enabled_when_option_set_to_bool_true(): void {
update_option( WPDO_Hook_Bus_Bridge::OPTION, true );
WPDO_Hook_Bus_Bridge::reset_cache();
$this->assertTrue( WPDO_Hook_Bus_Bridge::is_enabled() );
}
public function test_is_enabled_when_option_set_to_zero(): void {
update_option( WPDO_Hook_Bus_Bridge::OPTION, '0' );
WPDO_Hook_Bus_Bridge::reset_cache();
$this->assertFalse( WPDO_Hook_Bus_Bridge::is_enabled() );
}
public function test_is_enabled_caches_result(): void {
update_option( WPDO_Hook_Bus_Bridge::OPTION, '1' );
WPDO_Hook_Bus_Bridge::reset_cache();
$first = WPDO_Hook_Bus_Bridge::is_enabled();
// Mutate the option, but cache should retain previous value until reset.
update_option( WPDO_Hook_Bus_Bridge::OPTION, '0' );
$second = WPDO_Hook_Bus_Bridge::is_enabled();
$this->assertSame( $first, $second, 'Cache must be sticky within a request' );
// After explicit reset → new value visible.
WPDO_Hook_Bus_Bridge::reset_cache();
$this->assertFalse( WPDO_Hook_Bus_Bridge::is_enabled() );
}
// ── maybe_init_hook_bus() ───────────────────────────────────────────────
public function test_maybe_init_hook_bus_noop_when_disabled(): void {
// Disabled → must not throw even if WPDO_Hook_Bus class missing.
WPDO_Hook_Bus_Bridge::maybe_init_hook_bus();
$this->assertTrue( true );
}
// ── detect_intra_wpdo_conflicts() ───────────────────────────────────────
public function test_detect_intra_wpdo_conflicts_returns_empty_when_no_filters(): void {
// Stub bootstrap doesn't populate $wp_filter, so result is empty array.
$conflicts = WPDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts();
$this->assertIsArray( $conflicts );
$this->assertEmpty( $conflicts );
}
public function test_detect_intra_wpdo_conflicts_flags_multiple_wpdo_callbacks(): void {
// Simulate $wp_filter with two WPDO_* callbacks on the same hook.
$GLOBALS['wp_filter'] = array(
'update_post_metadata' => new class() {
public array $callbacks;
public function __construct() {
$this->callbacks = array(
10 => array(
array(
'function' => array(
new class() {
public function intercept_update() {}
},
'intercept_update',
),
),
),
8 => array(
array(
'function' => array(
new class() {
public function intercept_update() {}
},
'intercept_update',
),
),
),
);
}
},
);
// The anonymous classes won't have WPDO_ prefix → must not flag conflict.
$conflicts = WPDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts();
$this->assertEmpty( $conflicts, 'Non-WPDO classes must not be flagged' );
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Logger — lightweight error logger.
*/
class LoggerTest extends TestCase {
/** Last data array passed to $wpdb->insert(). */
public static array $last_insert = [];
/** Insert call counter. */
public static int $insert_count = 0;
/** Value returned by $wpdb->query(). */
public static int $query_return = 1;
/** Rows returned by $wpdb->get_results(). */
public static array $get_results_return = [];
/** Last SQL passed to $wpdb->query(). */
public static string $last_query_sql = '';
protected function setUp(): void {
self::$last_insert = [];
self::$insert_count = 0;
self::$query_return = 1;
self::$get_results_return = [];
self::$last_query_sql = '';
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public string $postmeta = 'wp_postmeta';
public string $posts = 'wp_posts';
public string $options = 'wp_options';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
public function get_var( string $sql ): ?string {
return null;
}
public function get_row( string $sql, $output = OBJECT ) {
return null;
}
public function get_results( string $sql, $output = OBJECT ): array {
return LoggerTest::$get_results_return;
}
public function insert( string $table, array $data, $format = null ): int|false {
LoggerTest::$last_insert = $data;
LoggerTest::$insert_count++;
return 1;
}
public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false {
return 1;
}
public function delete( string $table, array $where, $format = null ): int|false {
return 1;
}
public function query( string $sql ): int|bool {
LoggerTest::$last_query_sql = $sql;
return LoggerTest::$query_return;
}
};
}
// ── error() ──────────────────────────────────────────────────────────────
public function test_error_inserts_row_into_db(): void {
WPDO_Logger::error( 'reviews', 'save_hook', 'Something went wrong' );
$this->assertSame( 1, self::$insert_count );
}
public function test_error_sanitizes_module_field(): void {
WPDO_Logger::error( 'Reviews Module!', 'some_hook', 'test message' );
// sanitize_key strips non-[a-z0-9_-] chars; result matches stub output.
$this->assertSame( sanitize_key( 'Reviews Module!' ), self::$last_insert['module'] );
}
public function test_error_trims_hook_to_255_chars(): void {
$long_hook = str_repeat( 'x', 300 );
WPDO_Logger::error( 'mod', $long_hook, 'msg' );
$this->assertLessThanOrEqual( 255, strlen( self::$last_insert['hook'] ) );
}
public function test_error_encodes_context_as_json(): void {
$context = [ 'post_id' => 42, 'extra' => 'data' ];
WPDO_Logger::error( 'mod', 'hook', 'msg', $context );
$this->assertSame( json_encode( $context, JSON_UNESCAPED_UNICODE ), self::$last_insert['context'] );
}
public function test_error_sets_null_context_when_empty(): void {
WPDO_Logger::error( 'mod', 'hook', 'msg' );
$this->assertNull( self::$last_insert['context'] );
}
// ── get_recent() ─────────────────────────────────────────────────────────
public function test_get_recent_returns_wpdb_results(): void {
self::$get_results_return = [
[ 'id' => 1, 'module' => 'reviews', 'message' => 'err1' ],
[ 'id' => 2, 'module' => 'hot', 'message' => 'err2' ],
];
$results = WPDO_Logger::get_recent();
$this->assertCount( 2, $results );
}
// ── purge() ───────────────────────────────────────────────────────────────
public function test_purge_returns_query_result(): void {
self::$query_return = 5;
$deleted = WPDO_Logger::purge( 30 );
$this->assertSame( 5, $deleted );
}
}
@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Member_Fields entity group registration.
*
* Verifies that register_entity_fields() correctly registers all four
* user groups with the expected field counts, types, and attributes.
*/
class MemberFieldsRegistrationTest extends TestCase {
protected function setUp(): void {
// Load the class under test if not already loaded.
if ( ! class_exists( 'WPDO_Member_Fields' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-member-fields.php';
}
if ( ! class_exists( 'WPDO_Adapter_User' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/class-tmdo-adapter-user.php';
}
// Reset Entity Registry and register the user adapter.
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
}
// ── Group presence ───────────────────────────────────────────────────────
public function test_register_entity_fields_returns_early_without_entity_registry(): void {
// Class_exists('WPDO_Entity_Registry') will be true here, so we just
// confirm calling the method twice (dedup guard) returns false the 2nd time.
WPDO_Member_Fields::register_entity_fields();
// Second call: groups already registered — register_group() returns false (dedup).
// No assertion needed: simply must not throw.
$this->assertTrue( true );
}
public function test_all_four_groups_are_registered(): void {
WPDO_Member_Fields::register_entity_fields();
foreach ( array( 'membership', 'activity', 'profile', 'sso' ) as $group ) {
$fields = WPDO_Entity_Registry::get_group_fields( 'user', $group );
$this->assertNotEmpty( $fields, "Group '{$group}' should have registered fields." );
}
}
// ── membership group ─────────────────────────────────────────────────────
public function test_membership_group_has_six_fields(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
$this->assertCount( 6, $fields );
}
public function test_membership_level_is_enum_with_five_options(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
$level = $this->find_field( $fields, 'membership_level' );
$this->assertNotNull( $level );
$this->assertSame( 'enum', $level['type'] );
$this->assertCount( 5, $level['options'] );
$this->assertContains( 'gold', $level['options'] );
$this->assertContains( 'platinum', $level['options'] );
}
public function test_membership_level_is_searchable(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
$level = $this->find_field( $fields, 'membership_level' );
$this->assertTrue( (bool) $level['searchable'] );
}
public function test_points_balance_is_integer_searchable(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
$field = $this->find_field( $fields, 'points_balance' );
$this->assertSame( 'integer', $field['type'] );
$this->assertTrue( (bool) $field['searchable'] );
$this->assertSame( 0, $field['default'] );
}
public function test_membership_expires_at_is_datetime_searchable(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' );
$field = $this->find_field( $fields, 'membership_expires_at' );
$this->assertSame( 'datetime', $field['type'] );
$this->assertTrue( (bool) $field['searchable'] );
}
// ── activity group ───────────────────────────────────────────────────────
public function test_activity_group_has_six_fields(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'activity' );
$this->assertCount( 6, $fields );
}
public function test_login_count_has_integer_type_and_zero_default(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'activity' );
$field = $this->find_field( $fields, 'login_count' );
$this->assertSame( 'integer', $field['type'] );
$this->assertSame( 0, $field['default'] );
}
public function test_account_flags_is_integer(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'activity' );
$field = $this->find_field( $fields, 'account_flags' );
$this->assertSame( 'integer', $field['type'] );
}
// ── profile group ────────────────────────────────────────────────────────
public function test_profile_group_has_five_fields(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'profile' );
$this->assertCount( 5, $fields );
}
public function test_specialties_is_json_type(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'profile' );
$field = $this->find_field( $fields, 'specialties' );
$this->assertSame( 'json', $field['type'] );
}
public function test_display_name_custom_is_fulltext_searchable(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'profile' );
$field = $this->find_field( $fields, 'display_name_custom' );
$this->assertTrue( (bool) $field['searchable'] );
$this->assertTrue( (bool) $field['fulltext'] );
}
// ── sso group ────────────────────────────────────────────────────────────
public function test_sso_group_has_seven_fields(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
$this->assertCount( 7, $fields );
}
public function test_hub_global_user_id_is_searchable(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
$field = $this->find_field( $fields, 'hub_global_user_id' );
$this->assertTrue( (bool) $field['searchable'] );
}
public function test_token_expires_at_is_datetime_searchable(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
$field = $this->find_field( $fields, 'token_expires_at' );
$this->assertSame( 'datetime', $field['type'] );
$this->assertTrue( (bool) $field['searchable'] );
}
public function test_refresh_token_enc_is_textarea(): void {
WPDO_Member_Fields::register_entity_fields();
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
$field = $this->find_field( $fields, 'refresh_token_enc' );
$this->assertSame( 'textarea', $field['type'] );
}
// ── register() hooks add_action ──────────────────────────────────────────
public function test_register_hooks_wpdo_register_entity_fields(): void {
// add_action is a no-op stub in test bootstrap; just confirm no exception.
$this->assertNull( WPDO_Member_Fields::register() );
}
// ── helper ───────────────────────────────────────────────────────────────
/**
* Find a field definition by key within a fields array.
*
* @param array $fields Array of field definitions.
* @param string $key Meta key to find.
* @return array|null
*/
private function find_field( array $fields, string $key ): ?array {
foreach ( $fields as $field ) {
if ( $field['key'] === $key ) {
return $field;
}
}
return null;
}
}
@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Points_Manager.
*
* Uses a custom $wpdb mock that tracks which SQL verbs were executed
* (BEGIN/START TRANSACTION, COMMIT, ROLLBACK) so we can verify
* transaction discipline without hitting a real database.
*/
class PointsManagerTest extends TestCase {
/** @var object Original $wpdb mock from bootstrap. */
private object $original_wpdb;
protected function setUp(): void {
if ( ! class_exists( 'WPDO_Points_Manager' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-points-manager.php';
}
global $wpdb;
$this->original_wpdb = $wpdb;
// Install a controllable mock that also has insert_id.
$wpdb = $this->make_wpdb_mock();
}
protected function tearDown(): void {
global $wpdb;
$wpdb = $this->original_wpdb;
}
// ── credit() input validation ────────────────────────────────────────────
public function test_credit_rejects_zero_delta(): void {
$result = WPDO_Points_Manager::credit( 1, 0 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'credit delta must be positive', $result['error'] );
}
public function test_credit_rejects_negative_delta(): void {
$result = WPDO_Points_Manager::credit( 1, -50 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'credit delta must be positive', $result['error'] );
}
// ── debit() input validation ─────────────────────────────────────────────
public function test_debit_rejects_zero_delta(): void {
$result = WPDO_Points_Manager::debit( 1, 0 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'debit delta must be positive', $result['error'] );
}
public function test_debit_rejects_negative_delta(): void {
$result = WPDO_Points_Manager::debit( 1, -10 );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'debit delta must be positive', $result['error'] );
}
// ── debit() insufficient balance ─────────────────────────────────────────
public function test_debit_fails_when_balance_zero_and_no_overdraft(): void {
// $wpdb->get_var returns null → balance = 0; debit 50 → new_balance = -50 → reject.
$result = WPDO_Points_Manager::debit( 42, 50, 'purchase' );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'insufficient_balance', $result['error'] );
}
public function test_debit_rollback_called_on_insufficient_balance(): void {
global $wpdb;
WPDO_Points_Manager::debit( 42, 50 );
$sql_log = $wpdb->queries;
// Expect BEGIN and ROLLBACK but NOT COMMIT.
$this->assertContains( 'START TRANSACTION', $sql_log );
$this->assertContains( 'ROLLBACK', $sql_log );
$this->assertNotContains( 'COMMIT', $sql_log );
}
// ── debit() allow_overdraft ───────────────────────────────────────────────
public function test_debit_with_allow_overdraft_succeeds_below_zero(): void {
$result = WPDO_Points_Manager::debit( 1, 100, 'force', 0, '', true );
$this->assertTrue( $result['ok'] );
$this->assertSame( -100, $result['balance'] );
}
// ── credit() happy path ───────────────────────────────────────────────────
public function test_credit_returns_ok_and_new_balance(): void {
$result = WPDO_Points_Manager::credit( 7, 200, 'signup_bonus' );
$this->assertTrue( $result['ok'] );
$this->assertSame( 200, $result['balance'] );
$this->assertArrayHasKey( 'ledger_id', $result );
}
public function test_credit_records_begin_and_commit(): void {
global $wpdb;
WPDO_Points_Manager::credit( 7, 100, 'test' );
$sql_log = $wpdb->queries;
$this->assertContains( 'START TRANSACTION', $sql_log );
$this->assertContains( 'COMMIT', $sql_log );
$this->assertNotContains( 'ROLLBACK', $sql_log );
}
public function test_credit_truncates_long_reason(): void {
// Reasons over 60 chars must be silently truncated (not cause DB error).
$long_reason = str_repeat( 'x', 100 );
$result = WPDO_Points_Manager::credit( 5, 10, $long_reason );
$this->assertTrue( $result['ok'] );
}
// ── get_balance() ─────────────────────────────────────────────────────────
public function test_get_balance_returns_zero_for_unknown_user(): void {
// Mock $wpdb->get_var returns null → (int) null = 0.
$balance = WPDO_Points_Manager::get_balance( 9999 );
$this->assertSame( 0, $balance );
}
// ── get_ledger() ─────────────────────────────────────────────────────────
public function test_get_ledger_returns_empty_array_when_no_rows(): void {
$ledger = WPDO_Points_Manager::get_ledger( 9999 );
$this->assertSame( array(), $ledger );
}
public function test_get_ledger_clamps_limit_between_1_and_500(): void {
// Just confirm no exception on extreme inputs.
WPDO_Points_Manager::get_ledger( 1, -5 );
WPDO_Points_Manager::get_ledger( 1, 9999 );
$this->assertTrue( true );
}
// ── helper ───────────────────────────────────────────────────────────────
/**
* Build a $wpdb mock that:
* - Records every SQL statement to ->queries[]
* - Returns null for SELECT…FOR UPDATE (simulating empty DB / no row)
* - After a successful UPSERT, returns the inserted delta for re-read SELECTs
* - Returns empty array for get_results
* - Returns 1 for query/insert
* - Has insert_id = 99
*/
private function make_wpdb_mock(): object {
return new class {
public string $prefix = 'wp_';
public string $postmeta = 'wp_postmeta';
public string $posts = 'wp_posts';
public string $options = 'wp_options';
public string $usermeta = 'wp_usermeta';
public string $users = 'wp_users';
public array $queries = array();
public int $insert_id = 99;
public string $last_error = '';
public ?int $last_upserted_balance = null;
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%[sd]/', function () use ( &$i, $args ) {
return $args[ $i++ ] ?? '?';
}, $sql );
}
public function get_var( string $sql ): ?string {
$this->queries[] = $sql;
// SELECT … FOR UPDATE simulates an empty membership table (no row).
if ( false !== strpos( $sql, 'FOR UPDATE' ) ) {
return null;
}
// Post-UPSERT re-read returns the balance written by the last INSERT.
if ( null !== $this->last_upserted_balance ) {
return (string) $this->last_upserted_balance;
}
return null;
}
public function get_results( string $sql, $output = 'OBJECT' ): array {
$this->queries[] = $sql;
return array();
}
public function query( string $sql ): int {
$this->queries[] = $sql;
// Capture the delta from INSERT…VALUES(user_id, delta) so subsequent
// re-read SELECTs can return a meaningful balance (mirrors real DB).
if ( preg_match( '/VALUES\s*\(\s*\d+\s*,\s*(-?\d+)\s*\)/', $sql, $m ) ) {
$this->last_upserted_balance = (int) $m[1];
}
return 1;
}
public function insert( string $table, array $data, $format = null ): int {
$this->queries[] = "INSERT {$table}";
return 1;
}
public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int {
$this->queries[] = "UPDATE {$table}";
return 1;
}
public function delete( string $table, array $where, $format = null ): int {
$this->queries[] = "DELETE {$table}";
return 1;
}
public function replace( string $table, array $data, $format = null ): int {
$this->queries[] = "REPLACE {$table}";
return 1;
}
};
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Migration_Engine — 7-state static lifecycle controller.
*
* Validates valid/invalid transitions, rollback, and status.
* High-level operations (migrate/verify/cutover) require a registered
* migration instance and are tested at the transition level here.
*/
class MigrationEngineTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = [];
// Reset static migrations registry.
$ref = new ReflectionClass( WPDO_Migration_Engine::class );
$m = $ref->getProperty( 'migrations' );
$m->setAccessible( true );
$m->setValue( null, [] );
}
// ── can_transition ───────────────────────────────────────────────────────
/** @dataProvider valid_transitions_provider */
public function test_can_transition_returns_true_for_valid_paths( string $from, string $to ): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', $from );
$this->assertTrue(
WPDO_Migration_Engine::can_transition( 'hot_hp_listing', $to ),
"Expected valid transition: $from$to"
);
}
public static function valid_transitions_provider(): array {
return [
[ 'idle', 'dual_write' ],
[ 'dual_write', 'backfill' ],
[ 'backfill', 'verify' ],
[ 'backfill', 'dual_write' ], // backfill can go back to dual_write.
[ 'verify', 'cutover' ],
[ 'verify', 'dual_write' ], // verify can step back.
[ 'cutover', 'cleanup' ],
[ 'cleanup', 'complete' ],
// Any state → idle is always allowed (rollback path).
[ 'cutover', 'idle' ],
[ 'complete', 'idle' ],
];
}
/** @dataProvider invalid_transitions_provider */
public function test_can_transition_returns_false_for_invalid_paths( string $from, string $to ): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', $from );
$this->assertFalse(
WPDO_Migration_Engine::can_transition( 'hot_hp_listing', $to ),
"Expected invalid transition: $from$to"
);
}
public static function invalid_transitions_provider(): array {
return [
[ 'idle', 'cutover' ], // Must traverse intermediate states.
[ 'complete', 'backfill' ], // Cannot go backwards except to idle.
[ 'idle', 'complete' ],
];
}
// ── transition ───────────────────────────────────────────────────────────
public function test_transition_updates_state_on_valid_path(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
$result = WPDO_Migration_Engine::transition( 'hot_hp_listing', 'dual_write' );
$this->assertTrue( $result );
$this->assertSame( 'dual_write', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
}
public function test_transition_returns_false_and_preserves_state_on_invalid_path(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
$result = WPDO_Migration_Engine::transition( 'hot_hp_listing', 'complete' );
$this->assertFalse( $result );
$this->assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
}
// ── rollback ─────────────────────────────────────────────────────────────
public function test_rollback_resets_state_to_idle(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
$result = WPDO_Migration_Engine::rollback( 'hot_hp_listing' );
$this->assertSame( 'idle', $result['status'] );
$this->assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
}
public function test_rollback_from_idle_returns_idle_status(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' );
$result = WPDO_Migration_Engine::rollback( 'hot_hp_listing' );
// Engine returns idle status with "already idle" message (not error).
$this->assertSame( 'idle', $result['status'] );
}
// ── status ────────────────────────────────────────────────────────────────
public function test_status_returns_current_state(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
$status = WPDO_Migration_Engine::status( 'hot_hp_listing' );
$this->assertSame( 'backfill', $status['state'] );
$this->assertSame( 'hot_hp_listing', $status['module'] );
}
// ── cleanup / enable require prior states ───────────────────────────────
public function test_cleanup_fails_when_not_in_cutover(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' );
$result = WPDO_Migration_Engine::cleanup( 'hot_hp_listing' );
$this->assertSame( 'error', $result['status'] );
}
public function test_enable_fails_when_not_in_cleanup(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
$result = WPDO_Migration_Engine::enable( 'hot_hp_listing' );
$this->assertSame( 'error', $result['status'] );
}
public function test_enable_succeeds_from_cleanup(): void {
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cleanup' );
$result = WPDO_Migration_Engine::enable( 'hot_hp_listing' );
$this->assertSame( 'complete', $result['status'] );
$this->assertSame( 'complete', WPDO_Feature_Flags::get( 'hot_hp_listing' ) );
}
// ── migrate without registered migration ────────────────────────────────
public function test_migrate_without_registration_returns_error(): void {
$result = WPDO_Migration_Engine::migrate( 'hot_unregistered' );
$this->assertSame( 'error', $result['status'] );
}
}
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
if ( ! function_exists( 'is_email' ) ) {
function is_email( $v ): bool {
return is_string( $v ) && (bool) filter_var( $v, FILTER_VALIDATE_EMAIL );
}
}
if ( ! function_exists( 'wp_mail' ) ) {
function wp_mail( $to, $subject, $body, $headers = '', $attachments = array() ): bool {
$GLOBALS['_wpdo_mails'][] = compact( 'to', 'subject', 'body' );
return true;
}
}
if ( ! function_exists( 'sanitize_email' ) ) {
function sanitize_email( $v ): string {
return filter_var( (string) $v, FILTER_SANITIZE_EMAIL ) ?: '';
}
}
if ( ! function_exists( '__' ) ) {
function __( string $text, string $domain = 'default' ): string {
return $text;
}
}
if ( ! function_exists( 'home_url' ) ) {
function home_url( string $path = '/' ): string {
return 'https://example.test' . $path;
}
}
if ( ! function_exists( 'admin_url' ) ) {
function admin_url( string $path = '' ): string {
return 'https://example.test/wp-admin/' . ltrim( $path, '/' );
}
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/notifications/class-tmdo-email-notifier.php';
/**
* Unit tests for WPDO_Email_Notifier (v2.4.0 M10).
*/
class EmailNotifierTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wpdo_mails'] = array();
}
private function sample_summary( int $crit = 1 ): array {
return array(
'critical_count' => $crit,
'recommended_count' => 0,
'ran_at' => '2026-04-28 03:30:00',
'tests' => array(
'wpdo_schema_drift' => array(
'status' => 'critical',
'description' => 'Missing tables: wpdo_audit',
),
'wpdo_error_budget' => array(
'status' => 'good',
'description' => 'OK',
),
),
);
}
public function test_default_disabled(): void {
$this->assertFalse( WPDO_Email_Notifier::is_enabled() );
}
public function test_recipient_falls_back_to_admin_email(): void {
update_option( 'admin_email', 'admin@example.com', false );
$this->assertSame( 'admin@example.com', WPDO_Email_Notifier::recipient() );
update_option( 'wpdo_alert_email', 'alerts@example.com', false );
$this->assertSame( 'alerts@example.com', WPDO_Email_Notifier::recipient() );
}
public function test_throttle_hours_clamps_to_range(): void {
update_option( 'wpdo_alert_throttle_hours', 0, false );
$this->assertSame( 1, WPDO_Email_Notifier::throttle_hours() );
update_option( 'wpdo_alert_throttle_hours', 999, false );
$this->assertSame( 168, WPDO_Email_Notifier::throttle_hours() );
update_option( 'wpdo_alert_throttle_hours', 12, false );
$this->assertSame( 12, WPDO_Email_Notifier::throttle_hours() );
}
public function test_maybe_send_skips_when_disabled(): void {
$result = WPDO_Email_Notifier::maybe_send( $this->sample_summary() );
$this->assertFalse( $result );
$this->assertEmpty( $GLOBALS['_wpdo_mails'] );
}
public function test_maybe_send_sends_when_enabled(): void {
update_option( 'wpdo_email_alerts_enabled', '1', false );
update_option( 'wpdo_alert_email', 'ops@example.com', false );
$result = WPDO_Email_Notifier::maybe_send( $this->sample_summary() );
$this->assertTrue( $result );
$this->assertCount( 1, $GLOBALS['_wpdo_mails'] );
$mail = $GLOBALS['_wpdo_mails'][0];
$this->assertSame( 'ops@example.com', $mail['to'] );
$this->assertStringContainsString( 'wpdo_schema_drift', $mail['body'] );
$this->assertStringContainsString( 'WPDO 警告', $mail['subject'] );
}
public function test_maybe_send_throttle_dedupes_same_fingerprint(): void {
update_option( 'wpdo_email_alerts_enabled', '1', false );
update_option( 'wpdo_alert_email', 'ops@example.com', false );
$summary = $this->sample_summary();
$first = WPDO_Email_Notifier::maybe_send( $summary );
$second = WPDO_Email_Notifier::maybe_send( $summary );
$this->assertTrue( $first );
$this->assertFalse( $second, '同 fingerprint 第 2 次應 throttle' );
$this->assertCount( 1, $GLOBALS['_wpdo_mails'] );
}
public function test_maybe_send_skips_invalid_email(): void {
update_option( 'wpdo_email_alerts_enabled', '1', false );
update_option( 'wpdo_alert_email', 'not-an-email', false );
$result = WPDO_Email_Notifier::maybe_send( $this->sample_summary() );
$this->assertFalse( $result );
}
public function test_fingerprint_changes_with_critical_count(): void {
update_option( 'wpdo_email_alerts_enabled', '1', false );
update_option( 'wpdo_alert_email', 'ops@example.com', false );
$first = WPDO_Email_Notifier::maybe_send( $this->sample_summary( 1 ) );
// Different critical_count → different fingerprint → not throttled.
$second = WPDO_Email_Notifier::maybe_send( $this->sample_summary( 5 ) );
$this->assertTrue( $first );
$this->assertTrue( $second, '不同 critical_count 應視為不同警告' );
$this->assertCount( 2, $GLOBALS['_wpdo_mails'] );
}
}
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
if ( ! function_exists( '__' ) ) {
function __( string $text, string $domain = 'default' ): string { return $text; }
}
if ( ! function_exists( 'home_url' ) ) {
function home_url( string $path = '/' ): string { return 'https://example.test' . $path; }
}
if ( ! function_exists( 'admin_url' ) ) {
function admin_url( string $path = '' ): string { return 'https://example.test/wp-admin/' . ltrim( $path, '/' ); }
}
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
public string $code;
public string $message;
public array $data;
public function __construct( string $code = '', string $message = '', $data = array() ) {
$this->code = $code;
$this->message = $message;
$this->data = (array) $data;
}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
public function get_error_data() { return $this->data; }
}
}
if ( ! function_exists( 'is_wp_error' ) ) {
function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; }
}
// Mock wp_remote_post — captures into $GLOBALS['_wpdo_remote_posts'] and returns simulated response.
if ( ! function_exists( 'wp_remote_post' ) ) {
function wp_remote_post( $url, $args = array() ) {
$GLOBALS['_wpdo_remote_posts'][] = array( 'url' => $url, 'args' => $args );
// Default 200 OK; test can override via $GLOBALS['_wpdo_remote_status'].
return array( 'response' => array( 'code' => $GLOBALS['_wpdo_remote_status'] ?? 200 ) );
}
}
if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) {
function wp_remote_retrieve_response_code( $resp ) {
return $resp['response']['code'] ?? 0;
}
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/notifications/abstract-class-tmdo-notifier.php';
require_once dirname( __DIR__, 3 ) . '/includes/notifications/class-tmdo-slack-notifier.php';
require_once dirname( __DIR__, 3 ) . '/includes/notifications/class-tmdo-discord-notifier.php';
require_once dirname( __DIR__, 3 ) . '/includes/notifications/class-tmdo-telegram-notifier.php';
/**
* Unit tests for v2.5.0 M15 multi-channel notifiers.
*/
class MultiChannelNotifierTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wpdo_remote_posts'] = array();
$GLOBALS['_wpdo_remote_status'] = 200;
}
private function summary(): array {
return array(
'critical_count' => 1,
'recommended_count' => 0,
'ran_at' => '2026-04-28 03:30:00',
'tests' => array(
'wpdo_schema_drift' => array(
'status' => 'critical',
'description' => 'Missing tables: wpdo_audit',
),
),
);
}
// ─── Slack ────────────────────────────────────────────────────────
public function test_slack_default_disabled(): void {
$this->assertFalse( WPDO_Slack_Notifier::is_enabled() );
}
public function test_slack_skips_send_when_disabled(): void {
$result = WPDO_Slack_Notifier::maybe_send( $this->summary() );
$this->assertFalse( $result );
$this->assertEmpty( $GLOBALS['_wpdo_remote_posts'] );
}
public function test_slack_skips_when_webhook_invalid(): void {
update_option( 'wpdo_slack_enabled', '1', false );
update_option( 'wpdo_slack_webhook', 'http://evil.com/wh', false );
$result = WPDO_Slack_Notifier::maybe_send( $this->summary() );
$this->assertFalse( $result );
}
public function test_slack_sends_with_valid_webhook(): void {
update_option( 'wpdo_slack_enabled', '1', false );
update_option( 'wpdo_slack_webhook', 'https://hooks.slack.com/services/T/B/X', false );
$result = WPDO_Slack_Notifier::maybe_send( $this->summary() );
$this->assertTrue( $result );
$this->assertCount( 1, $GLOBALS['_wpdo_remote_posts'] );
$captured = $GLOBALS['_wpdo_remote_posts'][0];
$this->assertSame( 'https://hooks.slack.com/services/T/B/X', $captured['url'] );
$payload = json_decode( (string) $captured['args']['body'], true );
$this->assertArrayHasKey( 'text', $payload );
$this->assertStringContainsString( 'WPDO 警告', $payload['text'] );
$this->assertStringContainsString( 'wpdo_schema_drift', $payload['text'] );
}
public function test_slack_throttle_dedupes(): void {
update_option( 'wpdo_slack_enabled', '1', false );
update_option( 'wpdo_slack_webhook', 'https://hooks.slack.com/services/T/B/X', false );
$first = WPDO_Slack_Notifier::maybe_send( $this->summary() );
$second = WPDO_Slack_Notifier::maybe_send( $this->summary() );
$this->assertTrue( $first );
$this->assertFalse( $second );
$this->assertCount( 1, $GLOBALS['_wpdo_remote_posts'] );
}
// ─── Discord ─────────────────────────────────────────────────────
public function test_discord_validates_webhook_prefix(): void {
update_option( 'wpdo_discord_enabled', '1', false );
update_option( 'wpdo_discord_webhook', 'https://attack.example.com/x', false );
$this->assertFalse( WPDO_Discord_Notifier::maybe_send( $this->summary() ) );
}
public function test_discord_sends_content_payload(): void {
update_option( 'wpdo_discord_enabled', '1', false );
update_option( 'wpdo_discord_webhook', 'https://discord.com/api/webhooks/123/abc', false );
$result = WPDO_Discord_Notifier::maybe_send( $this->summary() );
$this->assertTrue( $result );
$captured = $GLOBALS['_wpdo_remote_posts'][0];
$payload = json_decode( (string) $captured['args']['body'], true );
$this->assertArrayHasKey( 'content', $payload );
}
// ─── Telegram ────────────────────────────────────────────────────
public function test_telegram_skips_when_token_missing(): void {
update_option( 'wpdo_telegram_enabled', '1', false );
// No token / chat_id.
$this->assertFalse( WPDO_Telegram_Notifier::maybe_send( $this->summary() ) );
}
public function test_telegram_validates_token_format(): void {
update_option( 'wpdo_telegram_enabled', '1', false );
update_option( 'wpdo_telegram_bot_token', 'not_a_token', false );
update_option( 'wpdo_telegram_chat_id', '123', false );
$this->assertFalse( WPDO_Telegram_Notifier::maybe_send( $this->summary() ) );
}
public function test_telegram_sends_when_valid(): void {
update_option( 'wpdo_telegram_enabled', '1', false );
update_option( 'wpdo_telegram_bot_token', '123456:ABCDEFghijklmnopqrstuvwxyz0123456789', false );
update_option( 'wpdo_telegram_chat_id', '-1001234567890', false );
$result = WPDO_Telegram_Notifier::maybe_send( $this->summary() );
$this->assertTrue( $result );
$captured = $GLOBALS['_wpdo_remote_posts'][0];
$this->assertStringStartsWith( 'https://api.telegram.org/bot', $captured['url'] );
$payload = json_decode( (string) $captured['args']['body'], true );
$this->assertSame( '-1001234567890', $payload['chat_id'] );
$this->assertStringContainsString( '🚨', $payload['text'] );
}
// ─── Severity filter ─────────────────────────────────────────────
public function test_severity_critical_only_skips_when_no_critical(): void {
update_option( 'wpdo_slack_enabled', '1', false );
update_option( 'wpdo_slack_webhook', 'https://hooks.slack.com/services/T/B/X', false );
update_option( 'wpdo_slack_severity', 'critical_only', false );
$summary_recommended_only = array(
'critical_count' => 0,
'recommended_count' => 2,
'ran_at' => '2026-04-28 03:30:00',
'tests' => array(
'wpdo_error_budget' => array( 'status' => 'recommended', 'description' => 'too many errors' ),
),
);
$this->assertFalse( WPDO_Slack_Notifier::maybe_send( $summary_recommended_only ) );
}
// ─── Channel ID identity ────────────────────────────────────────
public function test_channel_ids(): void {
$this->assertSame( 'slack', WPDO_Slack_Notifier::channel_id() );
$this->assertSame( 'discord', WPDO_Discord_Notifier::channel_id() );
$this->assertSame( 'telegram', WPDO_Telegram_Notifier::channel_id() );
}
}
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Post_Fields entity group registration (v2.9.1).
*
* Verifies that register_entity_fields() correctly registers all seven
* post groups with the expected field counts, types, and post_type targets.
*
* Mirrors MemberFieldsRegistrationTest's structure.
*/
class PostFieldsRegistrationTest extends TestCase {
protected function setUp(): void {
if ( ! class_exists( 'WPDO_Post_Fields' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/integrations/class-tmdo-post-fields.php';
}
if ( ! class_exists( 'WPDO_Adapter_Post' ) ) {
require_once WPDO_PLUGIN_DIR . 'includes/adapters/class-tmdo-adapter-post.php';
}
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
}
// ── Group presence ───────────────────────────────────────────────────────
public function test_register_entity_fields_does_not_throw_on_double_call(): void {
WPDO_Post_Fields::register_entity_fields();
WPDO_Post_Fields::register_entity_fields(); // dedup guard
$this->assertTrue( true );
}
public function test_all_seven_groups_are_registered(): void {
WPDO_Post_Fields::register_entity_fields();
$expected = array(
'wp_core',
'attachment',
'wc_product',
'hp_listing_core',
'hp_request_core',
'hp_vendor_core',
'nav_menu_item',
);
foreach ( $expected as $group ) {
$fields = WPDO_Entity_Registry::get_group_fields( 'post', $group );
$this->assertNotEmpty( $fields, "Group '{$group}' should have registered fields." );
}
}
public function test_user_entity_groups_are_not_touched(): void {
// 🔒 Frozen contract: post fields registration must not register
// any group under entity_type='user'.
WPDO_Post_Fields::register_entity_fields();
$user_groups = WPDO_Entity_Registry::get_groups_for_type( 'user' );
$this->assertEmpty( $user_groups, 'WPDO_Post_Fields must not touch user entity registry.' );
}
// ── wp_core group (cross post_type) ──────────────────────────────────────
public function test_wp_core_group_has_expected_keys(): void {
WPDO_Post_Fields::register_entity_fields();
$keys = $this->get_field_keys( 'wp_core' );
$this->assertContains( '_thumbnail_id', $keys );
$this->assertContains( '_wp_page_template', $keys );
$this->assertContains( '_edit_last', $keys );
}
public function test_wp_core_thumbnail_is_searchable(): void {
WPDO_Post_Fields::register_entity_fields();
$field = $this->find_field( 'wp_core', '_thumbnail_id' );
$this->assertNotNull( $field );
$this->assertSame( 'integer', $field['type'] );
$this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
}
// ── attachment group ─────────────────────────────────────────────────────
public function test_attachment_group_has_expected_keys(): void {
WPDO_Post_Fields::register_entity_fields();
$keys = $this->get_field_keys( 'attachment' );
$this->assertContains( '_wp_attached_file', $keys );
$this->assertContains( '_wp_attachment_metadata', $keys );
$this->assertContains( '_wp_attachment_image_alt', $keys );
}
public function test_attachment_metadata_is_json_type(): void {
WPDO_Post_Fields::register_entity_fields();
$field = $this->find_field( 'attachment', '_wp_attachment_metadata' );
$this->assertNotNull( $field );
$this->assertSame( 'json', $field['type'] );
}
// ── wc_product group ─────────────────────────────────────────────────────
public function test_wc_product_group_has_19_keys(): void {
WPDO_Post_Fields::register_entity_fields();
$keys = $this->get_field_keys( 'wc_product' );
$this->assertCount( 19, $keys, 'wc_product group should register exactly 19 keys.' );
}
public function test_wc_product_critical_keys_present(): void {
WPDO_Post_Fields::register_entity_fields();
$keys = $this->get_field_keys( 'wc_product' );
foreach ( array( '_price', '_regular_price', '_sale_price', '_stock', '_stock_status', '_sku' ) as $key ) {
$this->assertContains( $key, $keys, "wc_product missing critical key: {$key}" );
}
}
public function test_wc_product_price_is_searchable_decimal(): void {
WPDO_Post_Fields::register_entity_fields();
$field = $this->find_field( 'wc_product', '_price' );
$this->assertNotNull( $field );
$this->assertSame( 'decimal', $field['type'] );
$this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
}
public function test_wc_product_stock_status_is_enum_searchable(): void {
WPDO_Post_Fields::register_entity_fields();
$field = $this->find_field( 'wc_product', '_stock_status' );
$this->assertNotNull( $field );
$this->assertSame( 'enum', $field['type'] );
$this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
$this->assertContains( 'instock', $field['options'] );
$this->assertContains( 'outofstock', $field['options'] );
}
// ── hp_listing_core group ────────────────────────────────────────────────
public function test_hp_listing_core_critical_keys_present(): void {
WPDO_Post_Fields::register_entity_fields();
$keys = $this->get_field_keys( 'hp_listing_core' );
foreach ( array( 'hp_price', 'hp_status', 'hp_featured', 'hp_verified', 'hp_vendor' ) as $key ) {
$this->assertContains( $key, $keys, "hp_listing_core missing critical key: {$key}" );
}
}
public function test_hp_listing_price_is_searchable_decimal(): void {
WPDO_Post_Fields::register_entity_fields();
$field = $this->find_field( 'hp_listing_core', 'hp_price' );
$this->assertNotNull( $field );
$this->assertSame( 'decimal', $field['type'] );
$this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
}
// ── nav_menu_item group ──────────────────────────────────────────────────
public function test_nav_menu_item_has_8_keys(): void {
WPDO_Post_Fields::register_entity_fields();
$keys = $this->get_field_keys( 'nav_menu_item' );
$this->assertCount( 8, $keys );
$this->assertContains( '_menu_item_type', $keys );
$this->assertContains( '_menu_item_object_id', $keys );
$this->assertContains( '_menu_item_url', $keys );
}
// ── Helpers ──────────────────────────────────────────────────────────────
/** @return string[] */
private function get_field_keys( string $group ): array {
$fields = WPDO_Entity_Registry::get_group_fields( 'post', $group );
return array_map( static fn( $f ) => $f['key'], $fields );
}
private function find_field( string $group, string $key ): ?array {
$fields = WPDO_Entity_Registry::get_group_fields( 'post', $group );
foreach ( $fields as $f ) {
if ( ( $f['key'] ?? '' ) === $key ) {
return $f;
}
}
return null;
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_API — public facade for partner plugins (PR-5).
*
* @covers WPDO_API
*/
class PublicApiTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_postmeta'] = array();
$GLOBALS['_wp_usermeta'] = array();
$GLOBALS['_wp_termmeta'] = array();
$GLOBALS['_wp_commentmeta'] = array();
$GLOBALS['_wp_options'] = array();
// Reset Schema_Registry singleton.
$ref = new ReflectionClass( WPDO_Schema_Registry::class );
$instance = $ref->getProperty( 'instance' );
$instance->setAccessible( true );
$instance->setValue( null, null );
// Reset Feature_Flags caches (state pollution between tests).
$ref = new ReflectionClass( WPDO_Feature_Flags::class );
foreach ( array( 'cache', 'shadow_cache' ) as $prop ) {
$p = $ref->getProperty( $prop );
$p->setAccessible( true );
$p->setValue( null, null );
}
// Reset Entity_Registry static state.
WPDO_Entity_Registry::init();
}
// ── get_field / set_field (post entity) ─────────────────────────────────
public function test_get_field_returns_postmeta_value(): void {
update_post_meta( 100, 'hp_price', '199.99' );
$this->assertSame( '199.99', WPDO_API::get_field( 100, 'hp_price' ) );
}
public function test_set_field_writes_postmeta(): void {
WPDO_API::set_field( 200, 'hp_price', '299.99' );
$this->assertSame( '299.99', get_post_meta( 200, 'hp_price', true ) );
}
public function test_get_field_missing_returns_empty_string_when_single(): void {
$this->assertSame( '', WPDO_API::get_field( 9999, 'nonexistent' ) );
}
// ── get_entity / set_entity (multi-entity) ──────────────────────────────
public function test_get_entity_post_dispatches_correctly(): void {
update_post_meta( 1, 'k', 'pv' );
$this->assertSame( 'pv', WPDO_API::get_entity( 'post', 1, 'k' ) );
}
public function test_get_entity_unknown_type_returns_null(): void {
$this->assertNull( WPDO_API::get_entity( 'invalid', 1, 'k' ) );
}
public function test_set_entity_unknown_type_returns_false(): void {
$this->assertFalse( WPDO_API::set_entity( 'invalid', 1, 'k', 'v' ) );
}
// ── is_field_registered ─────────────────────────────────────────────────
public function test_is_field_registered_returns_false_for_unknown(): void {
$this->assertFalse( WPDO_API::is_field_registered( 'post', 'unregistered_key' ) );
}
public function test_is_field_registered_true_after_schema_register(): void {
WPDO_Schema_Registry::instance()->register(
'test',
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2)',
'column' => 'hp_price',
)
);
$this->assertTrue( WPDO_API::is_field_registered( 'post', 'hp_price' ) );
}
// ── trace_storage ──────────────────────────────────────────────────────
public function test_trace_storage_unregistered_when_no_field(): void {
$this->assertSame(
'unregistered',
WPDO_API::trace_storage( 'post', 'unknown', 'hp_listing' )
);
}
public function test_trace_storage_postmeta_when_registered_but_not_cutover(): void {
WPDO_Schema_Registry::instance()->register(
'test',
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2)',
'column' => 'hp_price',
)
);
// Module starts in 'idle' → not read-custom → returns 'postmeta'.
$this->assertSame(
'postmeta',
WPDO_API::trace_storage( 'post', 'hp_price', 'hp_listing' )
);
}
public function test_trace_storage_zone_after_cutover(): void {
$GLOBALS['_wp_options'] = array();
$ref = new ReflectionClass( WPDO_Feature_Flags::class );
$cache = $ref->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, null );
WPDO_Schema_Registry::instance()->register(
'test',
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2)',
'column' => 'hp_price',
)
);
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
$this->assertSame(
'zone_hot',
WPDO_API::trace_storage( 'post', 'hp_price', 'hp_listing' )
);
}
}
+296
View File
@@ -0,0 +1,296 @@
<?php
/**
* Unit tests for WPDO_REST_API.
*
* Covers route registration, handler logic, permission callback,
* filter param extraction, and both zone-active + fallback paths.
*/
use PHPUnit\Framework\TestCase;
class RestApiTest extends TestCase {
private WPDO_REST_API $api;
protected function setUp(): void {
$this->api = new WPDO_REST_API();
// Reset globals.
$GLOBALS['_wp_options'] = [];
$GLOBALS['_wp_postmeta'] = [];
$GLOBALS['_wp_post_types'] = [];
$GLOBALS['_wp_current_user_can'] = [];
$GLOBALS['_wp_valid_nonces'] = [];
$_COOKIE = [];
// Reset Feature Flags cache via reflection.
$ff_ref = new ReflectionClass( WPDO_Feature_Flags::class );
$ff_prop = $ff_ref->getProperty( 'cache' );
$ff_prop->setAccessible( true );
$ff_prop->setValue( null, null );
// Reset Schema Registry singleton.
$ref = new ReflectionClass( WPDO_Schema_Registry::class );
$prop = $ref->getProperty( 'instance' );
$prop->setAccessible( true );
$prop->setValue( null, null );
}
// ── Route registration ────────────────────────────────────────────────────
public function test_register_routes_calls_register_rest_route(): void {
// register_rest_route is stubbed to return true — just confirm no exception.
$this->api->register_routes();
$this->assertTrue( true );
}
// ── Permission callback ───────────────────────────────────────────────────
public function test_require_manage_options_false_when_not_admin(): void {
$GLOBALS['_wp_current_user_can']['manage_options'] = false;
$this->assertFalse( $this->api->require_manage_options() );
}
public function test_require_manage_options_true_when_admin(): void {
$GLOBALS['_wp_current_user_can']['manage_options'] = true;
$this->assertTrue( $this->api->require_manage_options() );
}
// ── get_status ────────────────────────────────────────────────────────────
public function test_get_status_returns_version_and_engine(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/status' );
$response = $this->api->get_status( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertSame( WPDO_VERSION, $data['version'] );
$this->assertSame( 'mysql', $data['engine'] );
$this->assertArrayHasKey( 'fields', $data );
$this->assertArrayHasKey( 'modules', $data );
}
// ── get_listing (single) ──────────────────────────────────────────────────
public function test_get_listing_404_when_post_not_found(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/9999' );
$req->set_param( 'id', 9999 );
$response = $this->api->get_listing( $req );
$this->assertSame( 404, $response->get_status() );
}
public function test_get_listing_returns_postmeta_when_zones_idle(): void {
$GLOBALS['_wp_post_types'][42] = 'hp_listing';
$GLOBALS['_wp_postmeta'][42]['hp_price'] = '500';
$GLOBALS['_wp_postmeta'][42]['hp_description'] = 'Test desc';
// Register hot + cold fields.
$registry = WPDO_Schema_Registry::instance();
$registry->register( 'test', [
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'column' => 'hp_price',
'type' => 'decimal',
] );
$registry->register( 'test', [
'post_type' => 'hp_listing',
'meta_key' => 'hp_description',
'zone' => 'cold',
] );
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/42' );
$req->set_param( 'id', 42 );
$response = $this->api->get_listing( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertSame( 42, $data['id'] );
$this->assertSame( 'hp_listing', $data['post_type'] );
$this->assertSame( '500', $data['hp_price'] );
$this->assertSame( 'Test desc', $data['hp_description'] );
}
// ── get_stats ─────────────────────────────────────────────────────────────
public function test_get_stats_404_when_post_not_found(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/9999' );
$req->set_param( 'id', 9999 );
$response = $this->api->get_stats( $req );
$this->assertSame( 404, $response->get_status() );
}
public function test_get_stats_returns_view_count(): void {
$GLOBALS['_wp_post_types'][55] = 'hp_listing';
// Warm zone idle, falls back to postmeta.
$GLOBALS['_wp_postmeta'][55]['hp_view_count'] = '17';
$req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/55' );
$req->set_param( 'id', 55 );
$response = $this->api->get_stats( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertSame( 55, $data['post_id'] );
$this->assertIsInt( $data['view_count'] );
}
// ── get_listings (WP_Query fallback) ─────────────────────────────────────
public function test_get_listings_returns_200_via_wp_query_fallback(): void {
// Zone idle → WP_Query path.
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'hp_listing' );
$req->set_param( 'per_page', 10 );
$req->set_param( 'page', 1 );
$response = $this->api->get_listings( $req );
$this->assertSame( 200, $response->get_status() );
$this->assertIsArray( $response->get_data() );
}
// ── Pagination headers ────────────────────────────────────────────────────
public function test_listings_fallback_sets_pagination_headers(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'hp_listing' );
$req->set_param( 'per_page', 10 );
$req->set_param( 'page', 1 );
$response = $this->api->get_listings( $req );
$headers = $response->get_headers();
$this->assertArrayHasKey( 'X-WP-Total', $headers );
$this->assertArrayHasKey( 'X-WP-TotalPages', $headers );
}
// ── post_view ─────────────────────────────────────────────────────────────
public function test_post_view_403_without_nonce(): void {
$GLOBALS['_wp_post_types'][10] = 'hp_listing';
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/10/view' );
$req->set_param( 'id', 10 );
// No nonce set.
$response = $this->api->post_view( $req );
$this->assertSame( 403, $response->get_status() );
}
public function test_post_view_403_with_invalid_nonce(): void {
$GLOBALS['_wp_post_types'][11] = 'hp_listing';
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/11/view' );
$req->set_param( 'id', 11 );
$req->set_header( 'X-WP-Nonce', 'bad_nonce' );
$response = $this->api->post_view( $req );
$this->assertSame( 403, $response->get_status() );
}
public function test_post_view_404_when_post_not_found(): void {
$nonce = wp_create_nonce( 'wp_rest' );
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/9999/view' );
$req->set_param( 'id', 9999 );
$req->set_header( 'X-WP-Nonce', $nonce );
$response = $this->api->post_view( $req );
$this->assertSame( 404, $response->get_status() );
}
public function test_post_view_returns_view_count(): void {
$GLOBALS['_wp_post_types'][20] = 'hp_listing';
$GLOBALS['_wp_postmeta'][20]['hp_view_count'] = '5';
$nonce = wp_create_nonce( 'wp_rest' );
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/20/view' );
$req->set_param( 'id', 20 );
$req->set_header( 'X-WP-Nonce', $nonce );
$response = $this->api->post_view( $req );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertSame( 20, $data['post_id'] );
$this->assertIsInt( $data['view_count'] );
}
// ── post_view: rate limiting ──────────────────────────────────────────────
public function test_post_view_success_sets_set_cookie_header(): void {
$GLOBALS['_wp_post_types'][25] = 'hp_listing';
$nonce = wp_create_nonce( 'wp_rest' );
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/25/view' );
$req->set_param( 'id', 25 );
$req->set_header( 'X-WP-Nonce', $nonce );
$response = $this->api->post_view( $req );
$this->assertSame( 200, $response->get_status() );
$this->assertArrayHasKey( 'Set-Cookie', $response->get_headers() );
$this->assertStringContainsString( 'wpdo_view_25', $response->get_headers()['Set-Cookie'] );
}
public function test_post_view_429_when_ip_rate_limited(): void {
$GLOBALS['_wp_post_types'][30] = 'hp_listing';
$nonce = wp_create_nonce( 'wp_rest' );
// First call succeeds and sets IP transient.
$req1 = new WP_REST_Request( 'POST', '/wpdo/v1/listings/30/view' );
$req1->set_param( 'id', 30 );
$req1->set_header( 'X-WP-Nonce', $nonce );
$resp1 = $this->api->post_view( $req1 );
$this->assertSame( 200, $resp1->get_status() );
// Second call (same IP, within TTL) must be rate-limited.
$req2 = new WP_REST_Request( 'POST', '/wpdo/v1/listings/30/view' );
$req2->set_param( 'id', 30 );
$req2->set_header( 'X-WP-Nonce', $nonce );
$resp2 = $this->api->post_view( $req2 );
$this->assertSame( 429, $resp2->get_status() );
$this->assertSame( 'too_many_requests', $resp2->get_data()['code'] );
}
public function test_post_view_429_when_cookie_present(): void {
$GLOBALS['_wp_post_types'][35] = 'hp_listing';
$_COOKIE['wpdo_view_35'] = '1';
$nonce = wp_create_nonce( 'wp_rest' );
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/35/view' );
$req->set_param( 'id', 35 );
$req->set_header( 'X-WP-Nonce', $nonce );
$response = $this->api->post_view( $req );
$this->assertSame( 429, $response->get_status() );
$this->assertSame( 'too_many_requests', $response->get_data()['code'] );
}
public function test_post_view_429_increments_rate_limit_stats(): void {
$GLOBALS['_wp_post_types'][40] = 'hp_listing';
$_COOKIE['wpdo_view_40'] = '1'; // Trigger cookie block.
$nonce = wp_create_nonce( 'wp_rest' );
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/40/view' );
$req->set_param( 'id', 40 );
$req->set_header( 'X-WP-Nonce', $nonce );
$this->api->post_view( $req );
$stats = get_option( 'wpdo_rl_stats', [] );
$this->assertSame( 1, (int) ( $stats['40'] ?? 0 ) );
}
// ── get_status: rate_limit_stats ─────────────────────────────────────────
public function test_get_status_includes_rate_limit_stats(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/status' );
$data = $this->api->get_status( $req )->get_data();
$this->assertArrayHasKey( 'rate_limit_stats', $data );
$this->assertIsArray( $data['rate_limit_stats'] );
}
}
+240
View File
@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
// Stub WP_Error / is_wp_error / apply_filters / get_option / update_option for unit context.
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
public string $code;
public string $message;
public array $data;
public function __construct( string $code = '', string $message = '', $data = array() ) {
$this->code = $code;
$this->message = $message;
$this->data = (array) $data;
}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
public function get_error_data() { return $this->data; }
}
}
if ( ! function_exists( 'is_wp_error' ) ) {
function is_wp_error( $thing ): bool {
return $thing instanceof WP_Error;
}
}
if ( ! function_exists( '__' ) ) {
function __( string $text, string $domain = 'default' ): string {
return $text;
}
}
if ( ! function_exists( 'add_filter' ) ) {
function add_filter( string $hook, $cb, int $prio = 10, int $args = 1 ): bool {
$GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ][] = $cb;
return true;
}
}
if ( ! function_exists( 'remove_filter' ) ) {
function remove_filter( string $hook, $cb, int $prio = 10 ): bool {
if ( isset( $GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ] ) ) {
$GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ] = array_values( array_filter(
$GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ],
fn( $existing ) => $existing !== $cb
) );
}
return true;
}
}
// Override apply_filters to honor our registry (only for the FSM-related hooks).
if ( ! function_exists( '_wpdo_fsm_apply_filters' ) ) {
function _wpdo_fsm_apply_filters( string $hook, $value, ...$args ) {
if ( ! isset( $GLOBALS['_wpdo_fsm_filters'][ $hook ] ) ) {
return $value;
}
ksort( $GLOBALS['_wpdo_fsm_filters'][ $hook ] );
foreach ( $GLOBALS['_wpdo_fsm_filters'][ $hook ] as $callbacks ) {
foreach ( $callbacks as $cb ) {
$value = call_user_func( $cb, $value, ...$args );
}
}
return $value;
}
}
if ( ! function_exists( 'apply_filters' ) ) {
function apply_filters( string $hook, $value, ...$args ) {
return _wpdo_fsm_apply_filters( $hook, $value, ...$args );
}
}
if ( ! function_exists( '__return_true' ) ) {
function __return_true(): bool { return true; }
}
// Per-test filter / option mocks via $GLOBALS.
if ( ! isset( $GLOBALS['_wpdo_fsm_filters'] ) ) {
$GLOBALS['_wpdo_fsm_filters'] = array();
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-feature-flags.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-manager.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-writer.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-reader.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-pruner.php';
require_once dirname( __DIR__, 3 ) . '/includes/safety/class-tmdo-fsm-guard.php';
/**
* Unit tests for WPDO_FSM_Guard (v2.2.0 M2).
*
* Pure logic — does not exercise actual snapshot creation (Snapshot_Manager
* gracefully no-ops when DB / filesystem aren't available, which is fine for
* these tests that focus on the transition graph + classification rules).
*/
class FSMGuardTest extends TestCase {
protected function setUp(): void {
// Clear all filter callbacks so the FSM Guard runs without bypass.
// Other unit tests rely on the global bypass registered in bootstrap.
$GLOBALS['_wp_filter_callbacks'] = [];
}
protected function tearDown(): void {
// Restore global FSM bypass for subsequent test classes.
$GLOBALS['_wp_filter_callbacks'] = [];
add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
}
/**
* Test against the real apply_filters used by FSM_Guard. Bootstrap defines
* a trivial passthrough; tests that need filter behavior can swap in their
* own mock by overriding this method.
*/
private function with_bypass_filter_active( bool $active, callable $body ): void {
if ( $active ) {
add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
}
try {
$body();
} finally {
if ( $active ) {
remove_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
}
}
}
// ─── can_transition: forward graph ────────────────────────────────────
public function test_idle_to_dual_write_is_allowed(): void {
$result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'dual_write' );
$this->assertTrue( $result );
}
public function test_dual_write_to_backfill_is_allowed(): void {
$result = WPDO_FSM_Guard::can_transition( 'reviews', 'dual_write', 'backfill' );
$this->assertTrue( $result );
}
public function test_idle_to_cutover_is_blocked(): void {
$result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'cutover' );
$this->assertInstanceOf( WP_Error::class, $result );
$this->assertSame( 'wpdo_fsm_invalid_transition', $result->get_error_code() );
}
public function test_idle_to_complete_is_blocked(): void {
$result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'complete' );
$this->assertInstanceOf( WP_Error::class, $result );
}
public function test_complete_is_terminal_only_idle_allowed(): void {
// Forward from complete is blocked (terminal).
$result_forward = WPDO_FSM_Guard::can_transition( 'reviews', 'complete', 'cleanup' );
$this->assertInstanceOf( WP_Error::class, $result_forward );
// But rewind to idle is allowed.
$result_rewind = WPDO_FSM_Guard::can_transition( 'reviews', 'complete', 'idle' );
$this->assertTrue( $result_rewind );
}
public function test_any_state_to_idle_is_allowed(): void {
foreach ( array( 'dual_write', 'backfill', 'verify', 'cutover', 'cleanup', 'complete' ) as $from ) {
$result = WPDO_FSM_Guard::can_transition( 'reviews', $from, 'idle' );
$this->assertTrue( $result, "{$from} → idle should be allowed (rewind)" );
}
}
public function test_no_op_transition_is_allowed(): void {
$result = WPDO_FSM_Guard::can_transition( 'reviews', 'verify', 'verify' );
$this->assertTrue( $result );
}
public function test_skipping_states_in_forward_graph_is_blocked(): void {
// dual_write directly to cutover (skipping backfill+verify).
$result = WPDO_FSM_Guard::can_transition( 'reviews', 'dual_write', 'cutover' );
$this->assertInstanceOf( WP_Error::class, $result );
}
public function test_filter_bypass_overrides_block(): void {
// Without bypass: idle → cutover is blocked.
$blocked = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'cutover' );
$this->assertInstanceOf( WP_Error::class, $blocked );
// The bootstrap apply_filters() is a trivial passthrough that doesn't
// honor our registry — full filter behavior is covered by the
// integration suite. Here we verify that adding a filter is non-fatal
// (no exception); behavioral assertion is best-effort.
add_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
$result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'cutover' );
// In raw-PHP unit context this still returns WP_Error; in real WP it would return true.
$this->assertTrue( $result === true || $result instanceof WP_Error );
remove_filter( 'wpdo/fsm_guard/bypass', '__return_true' );
}
// ─── is_destructive classification ──────────────────────────────────
public function test_cutover_to_cleanup_is_destructive(): void {
$this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cutover', 'cleanup' ) );
}
public function test_cleanup_to_complete_is_destructive(): void {
$this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cleanup', 'complete' ) );
}
public function test_active_state_to_idle_is_destructive(): void {
$this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cutover', 'idle' ) );
$this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cleanup', 'idle' ) );
$this->assertTrue( WPDO_FSM_Guard::is_destructive( 'complete', 'idle' ) );
}
public function test_dual_write_to_idle_is_destructive(): void {
// dual_write is in ACTIVE_STATES, so reverting still abandons writes.
$this->assertTrue( WPDO_FSM_Guard::is_destructive( 'dual_write', 'idle' ) );
}
public function test_idle_to_dual_write_is_NOT_destructive(): void {
$this->assertFalse( WPDO_FSM_Guard::is_destructive( 'idle', 'dual_write' ) );
}
public function test_dual_write_to_backfill_is_NOT_destructive(): void {
$this->assertFalse( WPDO_FSM_Guard::is_destructive( 'dual_write', 'backfill' ) );
}
public function test_verify_to_cutover_is_NOT_destructive(): void {
// cutover writes still go to both wp_*meta AND custom; nothing is purged yet.
$this->assertFalse( WPDO_FSM_Guard::is_destructive( 'verify', 'cutover' ) );
}
// ─── error message includes context ────────────────────────────────
public function test_blocked_error_includes_module_and_states(): void {
$result = WPDO_FSM_Guard::can_transition( 'my_module', 'idle', 'verify' );
$this->assertInstanceOf( WP_Error::class, $result );
$msg = $result->get_error_message();
$this->assertStringContainsString( 'my_module', $msg );
$this->assertStringContainsString( 'idle', $msg );
$this->assertStringContainsString( 'verify', $msg );
$data = $result->get_error_data();
$this->assertSame( 'my_module', $data['module'] );
$this->assertSame( 'idle', $data['from'] );
$this->assertSame( 'verify', $data['to'] );
$this->assertSame( array( 'dual_write' ), $data['allowed'] );
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Schema_Registry — field-to-zone mapping singleton.
*/
class SchemaRegistryTest extends TestCase {
private WPDO_Schema_Registry $registry;
protected function setUp(): void {
// Reset singleton via reflection.
$ref = new ReflectionClass( WPDO_Schema_Registry::class );
$instance = $ref->getProperty( 'instance' );
$instance->setAccessible( true );
$instance->setValue( null, null );
$this->registry = WPDO_Schema_Registry::instance();
}
// ── register() ──────────────────────────────────────────────────────────
public function test_register_single_field(): void {
$this->registry->register( 'test', [
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => 'hp_price',
'indexed' => true,
] );
$field = $this->registry->get_field( 'hp_listing', 'hp_price' );
$this->assertNotNull( $field );
$this->assertSame( 'hot', $field['zone'] );
$this->assertSame( 'hp_price', $field['column'] );
$this->assertTrue( $field['indexed'] );
}
public function test_register_many_registers_all_fields(): void {
$this->registry->register_many( 'test', [
[ 'post_type' => 'hp_listing', 'meta_key' => 'hp_featured', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_featured' ],
[ 'post_type' => 'hp_listing', 'meta_key' => 'hp_verified', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_verified' ],
[ 'post_type' => 'hp_vendor', 'meta_key' => 'hp_verified', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_verified' ],
] );
$listing_hot = $this->registry->get_zone_fields_for_type( 'hot', 'hp_listing' );
$this->assertCount( 2, $listing_hot );
$vendor_hot = $this->registry->get_zone_fields_for_type( 'hot', 'hp_vendor' );
$this->assertCount( 1, $vendor_hot );
}
// ── get_field() ─────────────────────────────────────────────────────────
public function test_get_field_returns_null_for_unknown_key(): void {
$this->assertNull( $this->registry->get_field( 'hp_listing', 'hp_nonexistent' ) );
}
// ── get_field_zone() ────────────────────────────────────────────────────
public function test_get_field_zone_returns_correct_zone(): void {
$this->registry->register( 'test', [
'post_type' => 'hp_vendor',
'meta_key' => 'hp_description',
'zone' => 'cold',
'cache_group' => 'wpdo_cold',
'cache_ttl' => 3600,
] );
$zone = $this->registry->get_field_zone( 'hp_vendor', 'hp_description' );
$this->assertSame( 'cold', $zone );
}
public function test_get_field_zone_returns_null_for_unknown(): void {
$this->assertNull( $this->registry->get_field_zone( 'hp_listing', 'hp_missing' ) );
}
// ── get_hot_columns() ───────────────────────────────────────────────────
public function test_get_hot_columns_returns_column_to_data_type_map(): void {
$this->registry->register( 'test', [
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => 'hp_price',
] );
$cols = $this->registry->get_hot_columns( 'hp_listing' );
$this->assertArrayHasKey( 'hp_price', $cols );
$this->assertSame( 'decimal(10,2) NOT NULL DEFAULT 0', $cols['hp_price'] );
}
// ── Duplicate registration guard ────────────────────────────────────────
public function test_duplicate_registration_does_not_add_extra_entry(): void {
$field = [
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => 'hp_price',
];
$this->registry->register( 'test', $field );
$this->registry->register( 'test', $field );
$hot = $this->registry->get_zone_fields_for_type( 'hot', 'hp_listing' );
$this->assertCount( 1, $hot );
}
// ── get_stats() ─────────────────────────────────────────────────────────
public function test_get_stats_reflects_registered_fields(): void {
$this->registry->register_many( 'test', [
[ 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0', 'column' => 'hp_price' ],
[ 'post_type' => 'hp_listing', 'meta_key' => 'hp_featured', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_featured' ],
[ 'post_type' => 'hp_vendor', 'meta_key' => 'hp_desc', 'zone' => 'cold', 'cache_group' => 'g', 'cache_ttl' => 3600 ],
] );
$stats = $this->registry->get_stats();
$this->assertSame( 2, $stats['hot'] );
$this->assertSame( 1, $stats['cold'] );
$this->assertSame( 0, $stats['warm'] );
$this->assertSame( 0, $stats['archive'] );
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for the shadow_read_only sub-flag added by PR-4.
*
* Orthogonal to the main 7-state FSM. Only takes effect when a module is in
* the `verify` state.
*
* @covers WPDO_Feature_Flags::enable_shadow_read
* @covers WPDO_Feature_Flags::disable_shadow_read
* @covers WPDO_Feature_Flags::is_shadow_read_active
* @covers WPDO_Feature_Flags::all_shadow
*/
class ShadowReadFlagTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
// Reset both caches via reflection.
$ref = new ReflectionClass( WPDO_Feature_Flags::class );
foreach ( array( 'cache', 'shadow_cache' ) as $prop ) {
$p = $ref->getProperty( $prop );
$p->setAccessible( true );
$p->setValue( null, null );
}
}
public function test_default_is_inactive(): void {
$this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
}
public function test_enable_then_active_only_in_verify_state(): void {
WPDO_Feature_Flags::enable_shadow_read( 'hot_hp_listing' );
// idle → not active even though flag is on.
$this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
// dual_write → still not active.
WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' );
$this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
// verify → active.
WPDO_Feature_Flags::set( 'hot_hp_listing', 'verify' );
$this->assertTrue( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
// cutover → no longer active (verify-only sub-flag).
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
$this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
}
public function test_disable_clears_active(): void {
WPDO_Feature_Flags::enable_shadow_read( 'hot_hp_vendor' );
WPDO_Feature_Flags::set( 'hot_hp_vendor', 'verify' );
$this->assertTrue( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_vendor' ) );
WPDO_Feature_Flags::disable_shadow_read( 'hot_hp_vendor' );
$this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_vendor' ) );
}
public function test_all_shadow_returns_only_enabled_modules(): void {
WPDO_Feature_Flags::enable_shadow_read( 'mod_a' );
WPDO_Feature_Flags::enable_shadow_read( 'mod_b' );
WPDO_Feature_Flags::disable_shadow_read( 'mod_b' );
$flags = WPDO_Feature_Flags::all_shadow();
$this->assertArrayHasKey( 'mod_a', $flags );
$this->assertArrayNotHasKey( 'mod_b', $flags );
}
public function test_shadow_flag_independent_per_module(): void {
WPDO_Feature_Flags::enable_shadow_read( 'hot_hp_listing' );
WPDO_Feature_Flags::set( 'hot_hp_listing', 'verify' );
WPDO_Feature_Flags::set( 'hot_hp_vendor', 'verify' );
$this->assertTrue( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) );
$this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_vendor' ) );
}
}
@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
// Bootstrap missing WP filesystem helpers used by Snapshot_Manager (idempotent across tests).
if ( ! function_exists( 'wp_upload_dir' ) ) {
function wp_upload_dir(): array {
return array(
'basedir' => sys_get_temp_dir() . '/wpdo-test-uploads',
'baseurl' => 'http://localhost/uploads',
);
}
}
if ( ! function_exists( 'wp_mkdir_p' ) ) {
function wp_mkdir_p( string $dir ): bool {
if ( is_dir( $dir ) ) {
return true;
}
return mkdir( $dir, 0777, true );
}
}
if ( ! function_exists( 'esc_sql' ) ) {
function esc_sql( $s ): string {
return addslashes( (string) $s );
}
}
if ( ! function_exists( 'size_format' ) ) {
function size_format( int $bytes, int $decimals = 0 ): string {
return $bytes . 'B';
}
}
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php';
require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-feature-flags.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-manager.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-writer.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-reader.php';
require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-pruner.php';
/**
* Unit tests for WPDO_Snapshot_Manager + Writer + Reader (v2.2.0 M1).
*
* These tests exercise pure logic + filesystem-isolated paths in sys_get_temp_dir().
* Heavy integration cases (real DB dump + restore) are covered by the
* integration suite and the e2e/wp-data-optimizer/ Playwright tests.
*/
class SnapshotManagerTest extends TestCase {
protected function setUp(): void {
// Clean tmp upload dir.
$dir = sys_get_temp_dir() . '/wpdo-test-uploads/wpdo-backups';
if ( is_dir( $dir ) ) {
foreach ( glob( $dir . '/*' ) as $f ) {
if ( is_file( $f ) ) {
@unlink( $f ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
}
}
}
}
public function test_generate_id_format_and_uniqueness(): void {
$ref = new ReflectionClass( WPDO_Snapshot_Manager::class );
$method = $ref->getMethod( 'generate_id' );
$method->setAccessible( true );
$ids = array();
for ( $i = 0; $i < 50; $i++ ) {
$id = $method->invoke( null );
$this->assertMatchesRegularExpression( '/^wpdo_[a-z0-9]+_[a-f0-9]+$/', $id );
$ids[] = $id;
}
$this->assertCount( 50, array_unique( $ids ), '50 generated IDs should all be distinct' );
}
public function test_ensure_backup_dir_creates_dir_and_htaccess(): void {
$ok = WPDO_Snapshot_Manager::ensure_backup_dir();
$this->assertTrue( $ok );
$dir = WPDO_Snapshot_Manager::backup_dir();
$this->assertDirectoryExists( $dir );
$this->assertFileExists( $dir . '/.htaccess' );
$this->assertStringContainsString( 'Deny from all', file_get_contents( $dir . '/.htaccess' ) );
$this->assertFileExists( $dir . '/index.php' );
}
public function test_create_with_invalid_trigger_returns_error(): void {
$result = WPDO_Snapshot_Manager::create( 'totally_made_up_trigger', array() );
$this->assertFalse( $result['ok'] );
$this->assertSame( 'invalid_trigger', $result['error'] );
}
public function test_writer_escape_sql_value_handles_all_types(): void {
$w = new WPDO_Snapshot_Writer( 'wpdo_test_id', array() );
$ref = new ReflectionClass( WPDO_Snapshot_Writer::class );
$m = $ref->getMethod( 'escape_sql_value' );
$m->setAccessible( true );
$this->assertSame( 'NULL', $m->invoke( $w, null ) );
$this->assertSame( '0', $m->invoke( $w, false ) );
$this->assertSame( '1', $m->invoke( $w, true ) );
$this->assertSame( '42', $m->invoke( $w, 42 ) );
$this->assertSame( '3.14', $m->invoke( $w, 3.14 ) );
$this->assertSame( "'hello'", $m->invoke( $w, 'hello' ) );
$this->assertSame( "'don\\'t'", $m->invoke( $w, "don't" ) );
// Binary (non-utf8) should hex-encode.
$bin = "\x00\x01\xff\xfe";
$out = $m->invoke( $w, $bin );
$this->assertSame( '0x0001fffe', $out );
}
public function test_writer_is_safe_name_validates_table(): void {
global $wpdb;
$saved_prefix = $wpdb->prefix ?? 'wp_';
$wpdb->prefix = 'wp_';
$w = new WPDO_Snapshot_Writer( 'wpdo_test_id', array() );
$ref = new ReflectionClass( WPDO_Snapshot_Writer::class );
$m = $ref->getMethod( 'is_safe_name' );
$m->setAccessible( true );
$this->assertTrue( $m->invoke( $w, 'wp_postmeta' ) );
$this->assertTrue( $m->invoke( $w, 'wp_wpdo_warm' ) );
$this->assertFalse( $m->invoke( $w, 'foo_postmeta' ), 'wrong prefix should be rejected' );
$this->assertFalse( $m->invoke( $w, 'wp_post; DROP TABLE' ), 'sql injection should be rejected' );
$this->assertFalse( $m->invoke( $w, 'wp_post-bad' ), 'dash should be rejected' );
$wpdb->prefix = $saved_prefix;
}
public function test_reader_parse_summary_extracts_table_row_counts(): void {
$catalog_row = array(
'snapshot_id' => 'wpdo_dummy',
'storage' => 'inline',
'size_bytes' => 100,
'inline_blob' => '',
);
$reader = new WPDO_Snapshot_Reader( $catalog_row );
$ref = new ReflectionClass( WPDO_Snapshot_Reader::class );
$m = $ref->getMethod( 'parse_summary' );
$m->setAccessible( true );
$sql = "-- header
INSERT INTO `wp_wpdo_warm` (`a`,`b`) VALUES (1,'x'),
(2,'y'),
(3,'z');
INSERT INTO `wp_wpdo_archive` (`a`) VALUES (10);
";
$summary = $m->invoke( $reader, $sql );
$this->assertSame( 4, $summary['total_rows'] );
$this->assertSame( 2, $summary['statements'] );
$this->assertSame( 3, $summary['tables']['wp_wpdo_warm'] );
$this->assertSame( 1, $summary['tables']['wp_wpdo_archive'] );
}
public function test_reader_verify_inline_size_match(): void {
$blob = "INSERT INTO `wp_wpdo_warm` VALUES (1);\n";
$reader = new WPDO_Snapshot_Reader( array(
'snapshot_id' => 'wpdo_dummy',
'storage' => 'inline',
'size_bytes' => strlen( $blob ),
'inline_blob' => $blob,
) );
$result = $reader->verify();
$this->assertTrue( $result['ok'] );
$this->assertTrue( $result['size_match'] );
$this->assertSame( 'inline', $result['storage'] );
}
public function test_reader_verify_inline_size_mismatch(): void {
$blob = "AAA";
$reader = new WPDO_Snapshot_Reader( array(
'snapshot_id' => 'wpdo_dummy',
'storage' => 'inline',
'size_bytes' => 999, // claim size that doesn't match.
'inline_blob' => $blob,
) );
$result = $reader->verify();
$this->assertFalse( $result['ok'] );
$this->assertFalse( $result['size_match'] );
}
public function test_reader_verify_file_missing(): void {
$reader = new WPDO_Snapshot_Reader( array(
'snapshot_id' => 'wpdo_dummy',
'storage' => 'file',
'size_bytes' => 100,
'file_path' => '/non/existent/path.sql.gz',
'file_sha256' => str_repeat( '0', 64 ),
) );
$result = $reader->verify();
$this->assertFalse( $result['ok'] );
$this->assertSame( 'file_missing', $result['error'] );
}
public function test_reader_maybe_gunzip_decompresses_real_gzip(): void {
$plaintext = "INSERT INTO `wp_wpdo_warm` VALUES (1);\n";
$gzipped = gzencode( $plaintext );
$catalog_row = array(
'snapshot_id' => 'wpdo_dummy',
'storage' => 'inline',
'size_bytes' => strlen( $gzipped ),
'inline_blob' => $gzipped,
);
$reader = new WPDO_Snapshot_Reader( $catalog_row );
$ref = new ReflectionClass( WPDO_Snapshot_Reader::class );
$m = $ref->getMethod( 'maybe_gunzip' );
$m->setAccessible( true );
$out = $m->invoke( $reader, $gzipped );
$this->assertSame( $plaintext, $out );
// Plain text should pass through untouched.
$out_plain = $m->invoke( $reader, $plaintext );
$this->assertSame( $plaintext, $out_plain );
}
public function test_reader_load_sql_inline_decompresses(): void {
$plaintext = "INSERT INTO `wp_test` VALUES (1);\n";
$gzipped = gzencode( $plaintext );
$reader = new WPDO_Snapshot_Reader( array(
'snapshot_id' => 'wpdo_dummy',
'storage' => 'inline',
'size_bytes' => strlen( $gzipped ),
'inline_blob' => $gzipped,
) );
$ref = new ReflectionClass( WPDO_Snapshot_Reader::class );
$m = $ref->getMethod( 'load_sql' );
$m->setAccessible( true );
$out = $m->invoke( $reader );
$this->assertSame( $plaintext, $out );
}
public function test_reader_throws_on_missing_required_keys(): void {
$this->expectException( InvalidArgumentException::class );
new WPDO_Snapshot_Reader( array() );
}
public function test_pruner_protected_triggers_listed(): void {
$ref = new ReflectionClass( WPDO_Snapshot_Pruner::class );
$prop = $ref->getReflectionConstant( 'PROTECTED_TRIGGERS' );
$this->assertNotNull( $prop );
$value = $prop->getValue();
$this->assertContains( 'pre_uninstall', $value );
$this->assertContains( 'pre_v2_upgrade', $value );
}
public function test_manager_valid_triggers_constant(): void {
$this->assertContains( 'manual', WPDO_Snapshot_Manager::VALID_TRIGGERS );
$this->assertContains( 'pre_fsm_transition', WPDO_Snapshot_Manager::VALID_TRIGGERS );
$this->assertContains( 'pre_v2_upgrade', WPDO_Snapshot_Manager::VALID_TRIGGERS );
$this->assertContains( 'scheduled', WPDO_Snapshot_Manager::VALID_TRIGGERS );
$this->assertContains( 'pre_uninstall', WPDO_Snapshot_Manager::VALID_TRIGGERS );
}
}
+212
View File
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Sync_Bridge — zone-aware dual-write dispatcher.
*
* Tests early-return guard conditions, write routing, and post cleanup.
*/
class SyncBridgeTest extends TestCase {
private WPDO_Sync_Bridge $bridge;
/** Capture last SQL passed to $wpdb->query(). */
public static string $last_query = '';
/** Configurable return value for $wpdb->get_var(). */
public static ?string $get_var_return = null;
protected function setUp(): void {
$this->bridge = new WPDO_Sync_Bridge();
// Reset globals.
$GLOBALS['_wp_options'] = [];
$GLOBALS['_wp_post_types'] = [];
self::$last_query = '';
self::$get_var_return = null;
// Reset private statics via reflection.
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
$ref->getProperty( 'bypassing' )->setValue( null, false );
$ref->getProperty( 'field_cache' )->setValue( null, [] );
// Reset Schema Registry singleton.
$sr = new ReflectionClass( WPDO_Schema_Registry::class );
$sr->getProperty( 'instance' )->setValue( null, null );
// Reset Feature Flags request cache.
$ff = new ReflectionClass( WPDO_Feature_Flags::class );
$ff->getProperty( 'cache' )->setValue( null, null );
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public string $postmeta = 'wp_postmeta';
public string $posts = 'wp_posts';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
public function get_var( string $sql ): ?string {
return SyncBridgeTest::$get_var_return;
}
public function get_row( string $sql, $output = OBJECT ) { return null; }
public function get_results( string $sql, $output = OBJECT ): array { return []; }
public function insert( string $table, array $data, $format = null ): int|false { return 1; }
public function update( string $table, array $data, array $where, $f = null, $wf = null ): int|false { return 1; }
public function delete( string $table, array $where, $format = null ): int|false { return 1; }
public function query( string $sql ): int|bool {
SyncBridgeTest::$last_query = $sql;
return 1;
}
};
}
// ── Helper: register a hot field ─────────────────────────────────────────
private function register_hot_field( string $post_type = 'hp_listing', string $meta_key = 'hp_price' ): void {
WPDO_Schema_Registry::instance()->register( 'test', [
'post_type' => $post_type,
'meta_key' => $meta_key,
'zone' => 'hot',
'column' => $meta_key,
'type' => 'decimal',
] );
}
// ── intercept_get: early-return guards ───────────────────────────────────
public function test_intercept_get_returns_null_when_bypassing(): void {
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
$ref->getProperty( 'bypassing' )->setValue( null, true );
$result = $this->bridge->intercept_get( null, 1, 'hp_price', true );
$this->assertNull( $result );
}
public function test_intercept_get_returns_null_for_zero_post_id(): void {
$result = $this->bridge->intercept_get( null, 0, 'hp_price', true );
$this->assertNull( $result );
}
public function test_intercept_get_returns_null_for_empty_meta_key(): void {
$result = $this->bridge->intercept_get( null, 1, '', true );
$this->assertNull( $result );
}
public function test_intercept_get_returns_null_when_post_type_unknown(): void {
// Post ID 99 not in _wp_post_types — get_post_type returns false.
$result = $this->bridge->intercept_get( null, 99, 'hp_price', true );
$this->assertNull( $result );
}
public function test_intercept_get_returns_null_when_field_not_registered(): void {
$GLOBALS['_wp_post_types'][1] = 'hp_listing';
// No field registered → returns unchanged $value.
$result = $this->bridge->intercept_get( null, 1, 'unregistered_key', true );
$this->assertNull( $result );
}
public function test_intercept_get_returns_null_when_module_not_cutover(): void {
$GLOBALS['_wp_post_types'][1] = 'hp_listing';
$this->register_hot_field();
// Module stays idle (not set) → is_read_custom returns false.
$result = $this->bridge->intercept_get( null, 1, 'hp_price', true );
$this->assertNull( $result );
}
public function test_intercept_get_returns_zone_value_when_cutover(): void {
$GLOBALS['_wp_post_types'][2] = 'hp_listing';
$this->register_hot_field();
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
self::$get_var_return = '42';
$result = $this->bridge->intercept_get( null, 2, 'hp_price', true );
// Returns array-wrapped value (WordPress unwraps on $single=true).
$this->assertSame( [ '42' ], $result );
}
public function test_intercept_get_returns_null_when_zone_returns_null(): void {
$GLOBALS['_wp_post_types'][3] = 'hp_listing';
$this->register_hot_field();
WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' );
self::$get_var_return = null; // Zone returns nothing.
$result = $this->bridge->intercept_get( null, 3, 'hp_price', true );
$this->assertNull( $result );
}
// ── intercept_update ─────────────────────────────────────────────────────
public function test_intercept_update_skips_when_bypassing(): void {
$ref = new ReflectionClass( WPDO_Sync_Bridge::class );
$ref->getProperty( 'bypassing' )->setValue( null, true );
$result = $this->bridge->intercept_update( null, 1, 'hp_price', '99', '' );
$this->assertNull( $result );
$this->assertEmpty( self::$last_query );
}
public function test_intercept_update_passes_through_when_no_field_registered(): void {
$GLOBALS['_wp_post_types'][1] = 'hp_listing';
// No field registered → returns $check unchanged.
$result = $this->bridge->intercept_update( null, 1, 'hp_price', '99', '' );
$this->assertNull( $result );
}
public function test_intercept_update_writes_to_zone_when_write_active(): void {
$GLOBALS['_wp_post_types'][5] = 'hp_listing';
$this->register_hot_field();
WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' );
$this->bridge->intercept_update( null, 5, 'hp_price', '150', '' );
// Zone Hot set() executes an UPSERT query.
$this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', self::$last_query );
}
// ── intercept_add ────────────────────────────────────────────────────────
public function test_intercept_add_writes_when_module_write_active(): void {
$GLOBALS['_wp_post_types'][6] = 'hp_listing';
$this->register_hot_field();
WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' );
$this->bridge->intercept_add( null, 6, 'hp_price', '200', false );
$this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', self::$last_query );
}
// ── cleanup_post ─────────────────────────────────────────────────────────
public function test_cleanup_post_does_nothing_for_unknown_post_type(): void {
// Post ID 999 has no type → early return.
$this->bridge->cleanup_post( 999 );
$this->assertEmpty( self::$last_query );
}
public function test_cleanup_post_deletes_hot_zone_data(): void {
$GLOBALS['_wp_post_types'][10] = 'hp_listing';
$this->register_hot_field();
$this->bridge->cleanup_post( 10 );
// WPDO_Zone_Hot::delete() calls $wpdb->delete() — but our mock captures query().
// The hot delete uses $wpdb->delete(), not query(). Just assert no exception thrown.
$this->assertTrue( true );
}
}
+302
View File
@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for WPDO_Zone_Archive — gzip-compressed historical archival.
*
* Uses an in-memory store to intercept $wpdb calls.
*/
class ZoneArchiveTest extends TestCase {
/** In-memory archive rows captured from $wpdb->insert() calls. */
public static array $store = [];
/** Arguments of the last $wpdb->delete() call. */
public static array $last_delete = [];
protected function setUp(): void {
self::$store = [];
self::$last_delete = [];
$GLOBALS['_wp_postmeta'] = [];
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
public function insert( string $table, array $data, $format = null ): int|false {
ZoneArchiveTest::$store[] = $data;
return 1;
}
public function delete( string $table, array $where, $format = null ): int|false {
ZoneArchiveTest::$last_delete = [ 'table' => $table, 'where' => $where ];
// Remove matching rows (single-column where only).
ZoneArchiveTest::$store = array_values( array_filter(
ZoneArchiveTest::$store,
static function ( array $row ) use ( $where ): bool {
foreach ( $where as $col => $val ) {
if ( isset( $row[ $col ] ) && (string) $row[ $col ] === (string) $val ) {
return false; // row matches → remove.
}
}
return true;
}
) );
return 1;
}
public function get_results( string $sql, $output = OBJECT ): array {
$flat = preg_replace( '/\s+/', ' ', $sql );
// stats() GROUP BY post_type query.
if ( stripos( $flat, 'GROUP BY post_type' ) !== false ) {
$by_type = [];
foreach ( ZoneArchiveTest::$store as $row ) {
$pt = $row['post_type'] ?? 'unknown';
$by_type[ $pt ] = ( $by_type[ $pt ] ?? 0 ) + 1;
}
$result = [];
foreach ( $by_type as $pt => $cnt ) {
$result[] = [ 'post_type' => $pt, 'cnt' => (string) $cnt ];
}
return $result;
}
// get() queries — filter by post_id and optional meta_key.
$post_id = null;
$meta_key = null;
if ( preg_match( '/post_id = (\d+)/', $flat, $m ) ) {
$post_id = (int) $m[1];
}
if ( preg_match( "/AND meta_key = '([^']+)'/", $flat, $m ) ) {
$meta_key = $m[1];
}
$result = [];
foreach ( ZoneArchiveTest::$store as $row ) {
if ( $post_id !== null && (int) ( $row['post_id'] ?? 0 ) !== $post_id ) {
continue;
}
if ( $meta_key !== null && ( $row['meta_key'] ?? '' ) !== $meta_key ) {
continue;
}
$result[] = [
'meta_key' => $row['meta_key'] ?? '',
'meta_value' => $row['meta_value'] ?? '',
'compressed' => $row['compressed'] ?? 0,
'archived_at' => $row['archived_at'] ?? '',
];
}
return $result;
}
public function get_var( string $sql ): ?string {
$flat = preg_replace( '/\s+/', ' ', $sql );
if ( stripos( $flat, 'WHERE compressed = 1' ) !== false ) {
$count = count( array_filter(
ZoneArchiveTest::$store,
static fn( array $r ) => (int) ( $r['compressed'] ?? 0 ) === 1
) );
return (string) $count;
}
if ( stripos( $flat, 'COUNT(*)' ) !== false ) {
return (string) count( ZoneArchiveTest::$store );
}
return null;
}
public function query( string $sql ): int|bool {
return 1; // BEGIN, COMMIT, ROLLBACK pass-through.
}
};
}
// ── table() ──────────────────────────────────────────────────────────────
public function test_table_returns_archive_table_name(): void {
$this->assertSame( 'wp_wpdo_archive', WPDO_Zone_Archive::table() );
}
// ── archive() ────────────────────────────────────────────────────────────
public function test_archive_inserts_row_with_correct_fields(): void {
WPDO_Zone_Archive::archive( 1, 'hp_listing', 'hp_price', '99.99' );
$this->assertCount( 1, self::$store );
$row = self::$store[0];
$this->assertSame( 1, $row['post_id'] );
$this->assertSame( 'hp_listing', $row['post_type'] );
$this->assertSame( 'hp_price', $row['meta_key'] );
$this->assertSame( '99.99', $row['meta_value'] );
}
public function test_archive_without_compress_keeps_plaintext_value(): void {
WPDO_Zone_Archive::archive( 2, 'hp_listing', 'hp_price', 'plain_value', 0, false );
$this->assertSame( 'plain_value', self::$store[0]['meta_value'] );
$this->assertSame( 0, self::$store[0]['compressed'] );
}
public function test_archive_with_compress_sets_compressed_flag(): void {
WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_price', 'compress_me', 0, true );
$this->assertSame( 1, self::$store[0]['compressed'] );
}
public function test_archive_with_compress_stores_base64_encoded_gzip(): void {
$original = 'hello compressed world';
WPDO_Zone_Archive::archive( 4, 'hp_listing', 'hp_bio', $original, 0, true );
$stored = self::$store[0]['meta_value'];
$decoded = base64_decode( $stored, true );
$restored = gzdecode( $decoded );
$this->assertSame( $original, $restored );
}
public function test_archive_stores_original_meta_id(): void {
WPDO_Zone_Archive::archive( 5, 'hp_listing', 'hp_price', '10', 42 );
$this->assertSame( 42, self::$store[0]['original_meta_id'] );
}
// ── archive_batch() ──────────────────────────────────────────────────────
public function test_archive_batch_inserts_all_entries(): void {
WPDO_Zone_Archive::archive_batch( [
[ 'post_id' => 10, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '100', 'meta_id' => 0 ],
[ 'post_id' => 11, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '200', 'meta_id' => 0 ],
[ 'post_id' => 12, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '300', 'meta_id' => 0 ],
] );
$this->assertCount( 3, self::$store );
}
public function test_archive_batch_with_empty_entries_is_safe(): void {
WPDO_Zone_Archive::archive_batch( [] );
$this->assertCount( 0, self::$store );
}
// ── get() ─────────────────────────────────────────────────────────────────
public function test_get_returns_empty_array_for_missing_post(): void {
$result = WPDO_Zone_Archive::get( 999 );
$this->assertSame( [], $result );
}
public function test_get_returns_all_entries_for_post(): void {
WPDO_Zone_Archive::archive( 20, 'hp_listing', 'hp_price', '50' );
WPDO_Zone_Archive::archive( 20, 'hp_listing', 'hp_category', '3' );
$result = WPDO_Zone_Archive::get( 20 );
$this->assertCount( 2, $result );
}
public function test_get_with_meta_key_filter_returns_only_matching(): void {
WPDO_Zone_Archive::archive( 21, 'hp_listing', 'hp_price', '150' );
WPDO_Zone_Archive::archive( 21, 'hp_listing', 'hp_category', '2' );
$result = WPDO_Zone_Archive::get( 21, 'hp_price' );
$this->assertCount( 1, $result );
$this->assertSame( 'hp_price', $result[0]['meta_key'] );
}
public function test_get_decompresses_gzipped_values(): void {
$original = 'hello decompressed world';
WPDO_Zone_Archive::archive( 22, 'hp_listing', 'hp_bio', $original, 0, true );
$result = WPDO_Zone_Archive::get( 22 );
$this->assertCount( 1, $result );
$this->assertSame( $original, $result[0]['meta_value'] );
}
public function test_get_removes_compressed_field_from_result(): void {
WPDO_Zone_Archive::archive( 23, 'hp_listing', 'hp_price', '10' );
$result = WPDO_Zone_Archive::get( 23 );
$this->assertArrayNotHasKey( 'compressed', $result[0] );
}
// ── restore() ────────────────────────────────────────────────────────────
public function test_restore_writes_to_post_meta(): void {
WPDO_Zone_Archive::archive( 30, 'hp_listing', 'hp_price', '77' );
WPDO_Zone_Archive::archive( 30, 'hp_listing', 'hp_category', '5' );
WPDO_Zone_Archive::restore( 30 );
$this->assertSame( '77', $GLOBALS['_wp_postmeta'][30]['hp_price'] );
$this->assertSame( '5', $GLOBALS['_wp_postmeta'][30]['hp_category'] );
}
public function test_restore_returns_correct_entry_count(): void {
WPDO_Zone_Archive::archive( 31, 'hp_listing', 'hp_price', '88' );
WPDO_Zone_Archive::archive( 31, 'hp_listing', 'hp_category', '9' );
$count = WPDO_Zone_Archive::restore( 31 );
$this->assertSame( 2, $count );
}
public function test_restore_returns_zero_for_missing_post(): void {
$count = WPDO_Zone_Archive::restore( 999 );
$this->assertSame( 0, $count );
}
// ── delete() ─────────────────────────────────────────────────────────────
public function test_delete_passes_correct_post_id_to_wpdb(): void {
WPDO_Zone_Archive::archive( 40, 'hp_listing', 'hp_price', '100' );
WPDO_Zone_Archive::delete( 40 );
$this->assertNotEmpty( self::$last_delete );
$this->assertSame( 40, self::$last_delete['where']['post_id'] );
}
// ── stats() ──────────────────────────────────────────────────────────────
public function test_stats_includes_required_keys(): void {
$stats = WPDO_Zone_Archive::stats();
$this->assertArrayHasKey( 'total_rows', $stats );
$this->assertArrayHasKey( 'compressed_rows', $stats );
$this->assertArrayHasKey( 'post_types', $stats );
}
public function test_stats_total_and_compressed_counts(): void {
WPDO_Zone_Archive::archive( 50, 'hp_listing', 'hp_price', '1', 0, true );
WPDO_Zone_Archive::archive( 51, 'hp_listing', 'hp_price', '2', 0, false );
WPDO_Zone_Archive::archive( 52, 'hp_listing', 'hp_price', '3', 0, true );
$stats = WPDO_Zone_Archive::stats();
$this->assertSame( 3, $stats['total_rows'] );
$this->assertSame( 2, $stats['compressed_rows'] );
}
public function test_stats_post_types_groups_by_type(): void {
WPDO_Zone_Archive::archive( 60, 'hp_listing', 'hp_price', '1' );
WPDO_Zone_Archive::archive( 61, 'hp_listing', 'hp_price', '2' );
WPDO_Zone_Archive::archive( 62, 'hp_vendor', 'hp_bio', 'x' );
$stats = WPDO_Zone_Archive::stats();
$by_type = array_column( $stats['post_types'], 'cnt', 'post_type' );
$this->assertSame( '2', $by_type['hp_listing'] );
$this->assertSame( '1', $by_type['hp_vendor'] );
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Zone_Classifier — zone suggestion engine.
*
* Tests private static methods via PHP Reflection.
*/
class ZoneClassifierTest extends TestCase {
/**
* Invoke a private static method on WPDO_Zone_Classifier via reflection.
*/
private function invoke_private( string $method, array $args ): mixed {
$ref = new ReflectionClass( WPDO_Zone_Classifier::class );
$m = $ref->getMethod( $method );
$m->setAccessible( true );
return $m->invokeArgs( null, $args );
}
// ── is_wp_internal() ─────────────────────────────────────────────────────
public function test_is_wp_internal_returns_true_for_edit_lock(): void {
$result = $this->invoke_private( 'is_wp_internal', [ '_edit_lock' ] );
$this->assertTrue( $result );
}
public function test_is_wp_internal_returns_true_for_thumbnail_id(): void {
$result = $this->invoke_private( 'is_wp_internal', [ '_thumbnail_id' ] );
$this->assertTrue( $result );
}
public function test_is_wp_internal_returns_false_for_hp_price(): void {
$result = $this->invoke_private( 'is_wp_internal', [ 'hp_price' ] );
$this->assertFalse( $result );
}
public function test_is_wp_internal_returns_false_for_custom_key(): void {
$result = $this->invoke_private( 'is_wp_internal', [ 'my_custom_meta' ] );
$this->assertFalse( $result );
}
// ── score_zones() — hot ──────────────────────────────────────────────────
public function test_score_zones_hot_for_numeric_short_values(): void {
$signals = $this->make_signals( [
'avg_length' => 10,
'numeric_ratio' => 0.9,
'distinct_values' => 5,
] );
$scores = $this->invoke_private( 'score_zones', [ $signals ] );
// Hot should outrank cold and archive.
$this->assertGreaterThan( $scores['cold'], $scores['hot'] );
$this->assertGreaterThan( $scores['archive'], $scores['hot'] );
}
// ── score_zones() — warm ─────────────────────────────────────────────────
public function test_score_zones_warm_for_transient_prefix(): void {
$signals = $this->make_signals( [ 'prefix' => 'transient' ] );
$scores = $this->invoke_private( 'score_zones', [ $signals ] );
$this->assertGreaterThanOrEqual( 0.8, $scores['warm'] );
}
// ── score_zones() — cold ─────────────────────────────────────────────────
public function test_score_zones_cold_for_long_json_values(): void {
$signals = $this->make_signals( [
'avg_length' => 300,
'is_json' => true,
] );
$scores = $this->invoke_private( 'score_zones', [ $signals ] );
$this->assertGreaterThan( $scores['hot'], $scores['cold'] );
$this->assertGreaterThan( $scores['archive'], $scores['cold'] );
}
// ── score_zones() — archive ──────────────────────────────────────────────
public function test_score_zones_archive_for_high_trash_ratio(): void {
$signals = $this->make_signals( [ 'trash_ratio' => 0.7 ] );
$scores = $this->invoke_private( 'score_zones', [ $signals ] );
$this->assertGreaterThanOrEqual( 0.6, $scores['archive'] );
}
// ── score_zones() — default ───────────────────────────────────────────────
public function test_score_zones_defaults_cold_when_no_signals(): void {
$signals = $this->make_signals( [] );
$scores = $this->invoke_private( 'score_zones', [ $signals ] );
// With avg_length=0, numeric_ratio=0, etc., hot gets 0.3 (avg_length<50 is true for 0).
// Cold must have at least a non-zero score (either from scoring or the fallback).
$max = max( $scores );
$this->assertGreaterThan( 0.0, $max );
$this->assertGreaterThan( 0.0, $scores['cold'] + $scores['hot'] ); // At least one has a score.
}
// ── build_reasons() ──────────────────────────────────────────────────────
public function test_build_reasons_hot_includes_numeric_message(): void {
$signals = $this->make_signals( [
'avg_length' => 10,
'numeric_ratio' => 0.9,
'distinct_values' => 5,
] );
$reasons = $this->invoke_private( 'build_reasons', [ $signals, 'hot' ] );
$combined = implode( ' ', $reasons );
$this->assertStringContainsStringIgnoringCase( 'numeric', $combined );
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Build a signals array with defaults, overriding specific keys.
*/
private function make_signals( array $overrides ): array {
return array_merge( [
'meta_key' => 'test_key',
'row_count' => 100,
'avg_length' => 0,
'max_length' => 0,
'distinct_values' => 0,
'numeric_ratio' => 0.0,
'trash_ratio' => 0.0,
'is_serialized' => false,
'is_json' => false,
'prefix' => '',
], $overrides );
}
}
+229
View File
@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Zone_Cold — JSON blob storage with Object Cache integration.
*/
class ZoneColdTest extends TestCase {
/** In-memory "database" store: post_id => json string */
public static array $db_store = [];
/** Control whether get_var returns the "id" existence check */
public static bool $row_exists = false;
protected function setUp(): void {
self::$db_store = [];
self::$row_exists = false;
$GLOBALS['_wp_cache'] = [];
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
/**
* get_var is used for two things:
* 1. SELECT data ... → return JSON blob
* 2. SELECT id ... → return '1' if exists, else null
*/
public function get_var( string $sql ): ?string {
$flat = preg_replace( '/\s+/', ' ', $sql );
// Existence check (save_blob path).
if ( stripos( $flat, 'SELECT id' ) !== false ) {
if ( preg_match( "/post_id = (\d+)/", $flat, $m ) ) {
return isset( ZoneColdTest::$db_store[ (int) $m[1] ] ) ? '1' : null;
}
return null;
}
// Data fetch.
if ( preg_match( "/post_id = (\d+)/", $flat, $m ) ) {
return ZoneColdTest::$db_store[ (int) $m[1] ] ?? null;
}
return null;
}
public function get_row( string $sql, $output = OBJECT ) {
return null;
}
public function get_results( string $sql, $output = OBJECT ): array {
return [];
}
public function insert( string $table, array $data, $format = null ): int|false {
if ( isset( $data['post_id'], $data['data'] ) ) {
ZoneColdTest::$db_store[ (int) $data['post_id'] ] = $data['data'];
}
return 1;
}
public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false {
if ( isset( $where['post_id'], $data['data'] ) ) {
ZoneColdTest::$db_store[ (int) $where['post_id'] ] = $data['data'];
}
return 1;
}
public function delete( string $table, array $where, $format = null ): int|false {
if ( isset( $where['post_id'] ) ) {
unset( ZoneColdTest::$db_store[ (int) $where['post_id'] ] );
}
return 1;
}
public function query( string $sql ): int|bool {
return 1;
}
};
}
// ── table() ──────────────────────────────────────────────────────────────
public function test_table_returns_prefixed_name(): void {
$this->assertSame( 'wp_wpdo_cold_hp_listing', WPDO_Zone_Cold::table( 'hp_listing' ) );
}
// ── get() ─────────────────────────────────────────────────────────────────
public function test_get_returns_null_for_missing_key(): void {
// Cache miss + no DB row → blob is empty array → key missing → null.
$result = WPDO_Zone_Cold::get( 99, 'hp_listing', 'hp_description' );
$this->assertNull( $result );
}
public function test_get_reads_from_cache_on_hit(): void {
// Pre-populate cache so DB should NOT be hit.
$group = 'wpdo_cold_hp_listing';
$cache_key = 'cold_1';
$GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'hp_bio' => 'cached value' ];
$result = WPDO_Zone_Cold::get( 1, 'hp_listing', 'hp_bio' );
$this->assertSame( 'cached value', $result );
// DB store should remain empty (DB was not queried for data).
$this->assertEmpty( self::$db_store );
}
// ── get_blob() ───────────────────────────────────────────────────────────
public function test_get_blob_queries_db_on_cache_miss(): void {
self::$db_store[5] = json_encode( [ 'hp_description' => 'Hello World', 'hp_location' => 'Paris' ] );
$blob = WPDO_Zone_Cold::get_blob( 5, 'hp_listing' );
$this->assertSame( 'Hello World', $blob['hp_description'] );
$this->assertSame( 'Paris', $blob['hp_location'] );
}
// ── set() ─────────────────────────────────────────────────────────────────
public function test_set_merges_new_key_into_blob(): void {
// Seed an existing blob.
self::$db_store[10] = json_encode( [ 'a' => 1 ] );
WPDO_Zone_Cold::set( 10, 'hp_listing', 'b', 2 );
$stored = json_decode( self::$db_store[10], true );
$this->assertArrayHasKey( 'a', $stored );
$this->assertArrayHasKey( 'b', $stored );
$this->assertSame( 1, $stored['a'] );
$this->assertSame( 2, $stored['b'] );
}
// ── set_many() ───────────────────────────────────────────────────────────
public function test_set_many_merges_multiple_keys(): void {
WPDO_Zone_Cold::set_many( 20, 'hp_listing', [
'key1' => 'v1',
'key2' => 'v2',
'key3' => 'v3',
] );
$stored = json_decode( self::$db_store[20], true );
$this->assertSame( 'v1', $stored['key1'] );
$this->assertSame( 'v2', $stored['key2'] );
$this->assertSame( 'v3', $stored['key3'] );
}
// ── remove() ─────────────────────────────────────────────────────────────
public function test_remove_deletes_key_from_blob(): void {
self::$db_store[30] = json_encode( [ 'keep' => 'yes', 'drop' => 'no' ] );
WPDO_Zone_Cold::remove( 30, 'hp_listing', 'drop' );
$stored = json_decode( self::$db_store[30], true );
$this->assertArrayHasKey( 'keep', $stored );
$this->assertArrayNotHasKey( 'drop', $stored );
}
// ── delete() ─────────────────────────────────────────────────────────────
public function test_delete_clears_cache(): void {
$group = 'wpdo_cold_hp_listing';
$cache_key = 'cold_1';
// Pre-populate cache.
$GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'some' => 'data' ];
WPDO_Zone_Cold::delete( 1, 'hp_listing' );
// Cache entry must be gone.
$this->assertFalse( isset( $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ) );
}
// ── Additional edge-case tests ────────────────────────────────────────────
public function test_set_invalidates_object_cache(): void {
$group = 'wpdo_cold_hp_listing';
$cache_key = 'cold_50';
// Pre-populate cache with stale data.
$GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'stale' => 'old_value' ];
WPDO_Zone_Cold::set( 50, 'hp_listing', 'fresh', 'new_value' );
// Cache must be invalidated after write.
$this->assertFalse( isset( $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ) );
}
public function test_get_blob_populates_cache_on_db_hit(): void {
// Seed the DB store so get_blob has something to fetch.
self::$db_store[60] = json_encode( [ 'cached_key' => 'cached_val' ] );
WPDO_Zone_Cold::get_blob( 60, 'hp_listing' );
// Cache must now contain the fetched data.
$group = 'wpdo_cold_hp_listing';
$cached = $GLOBALS['_wp_cache'][ $group ]['cold_60'] ?? false;
$this->assertIsArray( $cached );
$this->assertSame( 'cached_val', $cached['cached_key'] );
}
public function test_remove_nonexistent_key_is_safe(): void {
// Store an existing blob.
self::$db_store[70] = json_encode( [ 'keep' => 'this' ] );
// Remove a key that doesn't exist — should not throw.
WPDO_Zone_Cold::remove( 70, 'hp_listing', 'nonexistent_key' );
$stored = json_decode( self::$db_store[70], true );
$this->assertArrayHasKey( 'keep', $stored );
$this->assertArrayNotHasKey( 'nonexistent_key', $stored );
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Zone_Hot — flat-column custom tables for search/filter fields.
*
* Uses an in-memory mock to intercept $wpdb calls.
*/
class ZoneHotTest extends TestCase {
/** Last SQL query string passed to $wpdb->query(). */
public static string $last_query = '';
/** Arguments passed to $wpdb->delete(). */
public static array $last_delete = [];
/** Return value for get_var mock. */
public static ?string $get_var_return = null;
/** Return value for get_row mock. */
public static mixed $get_row_return = null;
protected function setUp(): void {
self::$last_query = '';
self::$last_delete = [];
self::$get_var_return = null;
self::$get_row_return = null;
$this->setup_wpdb_mock();
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
public function get_var( string $sql ): ?string {
return ZoneHotTest::$get_var_return;
}
public function get_row( string $sql, $output = OBJECT ) {
return ZoneHotTest::$get_row_return;
}
public function get_results( string $sql, $output = OBJECT ): array {
return [];
}
public function insert( string $table, array $data, $format = null ): int|false {
return 1;
}
public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false {
return 1;
}
public function delete( string $table, array $where, $format = null ): int|false {
ZoneHotTest::$last_delete = [ 'table' => $table, 'where' => $where ];
return 1;
}
public function query( string $sql ): int|bool {
ZoneHotTest::$last_query = $sql;
return 1;
}
};
}
// ── table() ──────────────────────────────────────────────────────────────
public function test_table_returns_prefixed_name(): void {
$this->assertSame( 'wp_wpdo_hot_hp_listing', WPDO_Zone_Hot::table( 'hp_listing' ) );
}
public function test_table_sanitizes_post_type(): void {
// The test sanitize_key stub strips non-[a-z0-9_-] chars then lowercases.
// 'HP Listing!' → strip uppercase H,P + space + '!' → 'isting' → lower → 'isting'.
$sanitized = sanitize_key( 'HP Listing!' );
$this->assertSame( 'wp_wpdo_hot_' . $sanitized, WPDO_Zone_Hot::table( 'HP Listing!' ) );
}
// ── get() ─────────────────────────────────────────────────────────────────
public function test_get_returns_null_when_row_missing(): void {
self::$get_var_return = null;
$result = WPDO_Zone_Hot::get( 1, 'hp_listing', 'hp_price' );
$this->assertNull( $result );
}
public function test_get_returns_value_from_db(): void {
self::$get_var_return = '42';
$result = WPDO_Zone_Hot::get( 1, 'hp_listing', 'hp_price' );
$this->assertSame( '42', $result );
}
// ── get_row() ────────────────────────────────────────────────────────────
public function test_get_row_returns_null_when_missing(): void {
self::$get_row_return = null;
$result = WPDO_Zone_Hot::get_row( 1, 'hp_listing' );
$this->assertNull( $result );
}
public function test_get_row_returns_array(): void {
$expected = [ 'post_id' => 1, 'hp_price' => '100', 'hp_location' => 'NYC' ];
self::$get_row_return = $expected;
$result = WPDO_Zone_Hot::get_row( 1, 'hp_listing' );
$this->assertSame( $expected, $result );
}
// ── set() ─────────────────────────────────────────────────────────────────
public function test_set_executes_upsert_query(): void {
WPDO_Zone_Hot::set( 5, 'hp_listing', 'hp_price', '99' );
$this->assertNotEmpty( self::$last_query, 'Expected $wpdb->query() to be called' );
$this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', self::$last_query );
}
// ── set_many() ───────────────────────────────────────────────────────────
public function test_set_many_includes_all_columns_in_upsert(): void {
WPDO_Zone_Hot::set_many( 7, 'hp_listing', [
'hp_price' => '150',
'hp_location' => 'LA',
'hp_category' => '3',
] );
$this->assertNotEmpty( self::$last_query );
$this->assertStringContainsString( 'hp_price', self::$last_query );
$this->assertStringContainsString( 'hp_location', self::$last_query );
$this->assertStringContainsString( 'hp_category', self::$last_query );
}
// ── delete() ─────────────────────────────────────────────────────────────
public function test_delete_calls_wpdb_delete(): void {
WPDO_Zone_Hot::delete( 42, 'hp_listing' );
$this->assertNotEmpty( self::$last_delete, 'Expected $wpdb->delete() to be called' );
$this->assertSame( 42, self::$last_delete['where']['post_id'] );
}
public function test_delete_table_name_contains_post_type(): void {
WPDO_Zone_Hot::delete( 5, 'hp_vendor' );
$this->assertStringContainsString( 'hp_vendor', self::$last_delete['table'] );
}
// ── Additional edge cases ─────────────────────────────────────────────────
public function test_table_for_different_post_type(): void {
$this->assertSame( 'wp_wpdo_hot_hp_vendor', WPDO_Zone_Hot::table( 'hp_vendor' ) );
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_Zone_Warm — Zone B KV table with TTL.
*
* Uses an in-memory array to simulate wpdb queries via a custom mock.
*/
class ZoneWarmTest extends TestCase {
/** In-memory warm store: post_id => meta_key => [value, expires_at] */
public static array $store = [];
protected function setUp(): void {
self::$store = [];
$this->setupWpdbMock();
}
private function setupWpdbMock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
public function get_var( string $sql ): ?string {
$store = &ZoneWarmTest::$store;
// Normalize whitespace so multiline SQL works with regex.
$flat = preg_replace( '/\s+/', ' ', $sql );
if ( preg_match( '/SELECT id.*post_id = (\d+).*meta_key = \'([^\']+)\'/', $flat, $m ) ) {
return isset( $store[ $m[1] ][ $m[2] ] ) ? '1' : null;
}
if ( preg_match( '/SELECT meta_value.*post_id = (\d+).*meta_key = \'([^\']+)\'/', $flat, $m ) ) {
$entry = $store[ $m[1] ][ $m[2] ] ?? null;
if ( ! $entry ) {
return null;
}
if ( $entry['expires_at'] && $entry['expires_at'] < time() ) {
return null;
}
return $entry['value'];
}
return null;
}
public function get_results( string $sql, $output = null ): array {
$store = ZoneWarmTest::$store;
$result = [];
foreach ( $store as $post_id => $keys ) {
foreach ( $keys as $meta_key => $entry ) {
if ( $entry['expires_at'] && $entry['expires_at'] < time() ) {
continue;
}
$result[] = (object) [ 'meta_key' => $meta_key, 'meta_value' => $entry['value'] ];
}
}
return $result;
}
public function insert( string $table, array $data, $format = null ): int|false {
ZoneWarmTest::$store[ $data['post_id'] ][ $data['meta_key'] ] = [
'value' => $data['meta_value'],
'expires_at' => isset( $data['expires_at'] ) ? strtotime( $data['expires_at'] ) : null,
];
return 1;
}
public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false {
// For simplicity, find by scanning store.
foreach ( ZoneWarmTest::$store as $post_id => &$keys ) {
foreach ( $keys as $meta_key => &$entry ) {
if ( isset( $data['meta_value'] ) ) {
$entry['value'] = $data['meta_value'];
}
if ( isset( $data['expires_at'] ) ) {
$entry['expires_at'] = strtotime( $data['expires_at'] );
}
}
}
return 1;
}
public function delete( string $table, array $where, $format = null ): int|false {
$post_id = $where['post_id'] ?? null;
$meta_key = $where['meta_key'] ?? null;
if ( $post_id && $meta_key ) {
unset( ZoneWarmTest::$store[ $post_id ][ $meta_key ] );
} elseif ( $post_id ) {
unset( ZoneWarmTest::$store[ $post_id ] );
}
return 1;
}
public function query( string $sql ): int|bool {
return 0;
}
};
}
// ── set / get ────────────────────────────────────────────────────────────
public function test_set_and_get_basic_value(): void {
WPDO_Zone_Warm::set( 1, 'hp_views', '42' );
$this->assertSame( '42', WPDO_Zone_Warm::get( 1, 'hp_views' ) );
}
public function test_get_returns_null_for_missing_key(): void {
$this->assertNull( WPDO_Zone_Warm::get( 99, 'missing_key' ) );
}
public function test_set_overwrites_existing_value(): void {
WPDO_Zone_Warm::set( 1, 'hp_views', '10' );
WPDO_Zone_Warm::set( 1, 'hp_views', '20' );
// The store update mock replaces all entries for simplicity.
$this->assertNotNull( WPDO_Zone_Warm::get( 1, 'hp_views' ) );
}
// ── TTL / expiry ─────────────────────────────────────────────────────────
public function test_set_with_ttl_stores_future_expiry(): void {
WPDO_Zone_Warm::set( 1, 'hp_views', '5', 3600 );
$this->assertSame( '5', WPDO_Zone_Warm::get( 1, 'hp_views' ) );
}
public function test_expired_entry_returns_null(): void {
// Insert directly with a past expiry.
self::$store[2]['hp_flag'] = [
'value' => 'should_be_gone',
'expires_at' => time() - 1, // expired 1 second ago.
];
$this->assertNull( WPDO_Zone_Warm::get( 2, 'hp_flag' ) );
}
// ── delete ───────────────────────────────────────────────────────────────
public function test_delete_removes_key(): void {
WPDO_Zone_Warm::set( 1, 'hp_views', '7' );
WPDO_Zone_Warm::delete( 1, 'hp_views' );
$this->assertNull( WPDO_Zone_Warm::get( 1, 'hp_views' ) );
}
public function test_delete_all_removes_all_post_keys(): void {
WPDO_Zone_Warm::set( 3, 'key_a', 'val_a' );
WPDO_Zone_Warm::set( 3, 'key_b', 'val_b' );
WPDO_Zone_Warm::delete_all( 3 );
$this->assertEmpty( self::$store[3] ?? [] );
}
// ── Additional tests ─────────────────────────────────────────────────────
public function test_table_returns_warm_table_name(): void {
$this->assertSame( 'wp_wpdo_warm', WPDO_Zone_Warm::table() );
}
public function test_delete_specific_key_leaves_other_keys_intact(): void {
WPDO_Zone_Warm::set( 4, 'key_keep', 'val_keep' );
WPDO_Zone_Warm::set( 4, 'key_drop', 'val_drop' );
WPDO_Zone_Warm::delete( 4, 'key_drop' );
$this->assertNull( WPDO_Zone_Warm::get( 4, 'key_drop' ) );
// key_keep should still be readable.
$this->assertNotNull( WPDO_Zone_Warm::get( 4, 'key_keep' ) );
}
public function test_different_posts_with_same_meta_key_are_independent(): void {
WPDO_Zone_Warm::set( 5, 'shared_key', 'value_for_5' );
WPDO_Zone_Warm::set( 6, 'shared_key', 'value_for_6' );
$this->assertSame( 'value_for_5', WPDO_Zone_Warm::get( 5, 'shared_key' ) );
$this->assertSame( 'value_for_6', WPDO_Zone_Warm::get( 6, 'shared_key' ) );
}
}