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