diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 7f61fbb..d041084 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -13,6 +13,9 @@ declare(strict_types=1); +// Load the real TMDO_Listing_Stats below instead of core's stub. +define( 'TMDO_TEST_SKIP_LISTING_STATS_STUB', true ); + $tmdo_core_bootstrap = dirname( __DIR__, 2 ) . '/2meet-data-optimizer/tests/bootstrap.php'; if ( ! file_exists( $tmdo_core_bootstrap ) ) { throw new RuntimeException( @@ -44,9 +47,7 @@ $tmdo_hp_files = array( 'includes/class-tmdo-hivepress-transient-filter.php', 'includes/class-tmdo-hivepress-term-comment-fields.php', 'includes/class-tmdo-hpct-import.php', - // class-tmdo-listing-stats.php is deliberately NOT loaded: the core unit - // bootstrap already declares a TMDO_Listing_Stats stub, and no test in this - // suite exercises the real implementation. + 'includes/class-tmdo-listing-stats.php', 'admin/class-tmdo-admin-hivepress.php', 'cli/class-tmdo-cli-hivepress.php', ); diff --git a/tests/unit/ListingStatsTest.php b/tests/unit/ListingStatsTest.php new file mode 100644 index 0000000..b33c043 --- /dev/null +++ b/tests/unit/ListingStatsTest.php @@ -0,0 +1,253 @@ + meta_key => [value, expires_at] */ + public static array $store = []; + + /** Flushed view counts captured during flush_views_to_postmeta(). */ + public static array $flushed = []; + + protected function setUp(): void { + self::$store = []; + self::$flushed = []; + $GLOBALS['_wp_postmeta'] = []; + $this->setupWpdbMock(); + } + + private function setupWpdbMock(): 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 { + $store = &ListingStatsTest::$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 { + $flat = preg_replace( '/\s+/', ' ', $sql ); + + // Postmeta batch query (e.g. hp_view_count) → read from $GLOBALS['_wp_postmeta']. + if ( strpos( $flat, 'wp_postmeta' ) !== false ) { + $result = []; + foreach ( $GLOBALS['_wp_postmeta'] ?? [] as $post_id => $meta ) { + if ( isset( $meta['hp_view_count'] ) ) { + $row = [ 'post_id' => (string) $post_id, 'meta_value' => (string) $meta['hp_view_count'] ]; + $result[] = $output === ARRAY_A ? $row : (object) $row; + } + } + return $result; + } + + // Warm zone query → return from store, filtered by meta_key in SQL. + $store = ListingStatsTest::$store; + $result = []; + $filter_key = null; + if ( preg_match( "/meta_key = '([^']+)'/", $flat, $m ) ) { + $filter_key = $m[1]; + } + foreach ( $store as $post_id => $keys ) { + foreach ( $keys as $meta_key => $entry ) { + if ( $filter_key && $meta_key !== $filter_key ) { + continue; + } + if ( $entry['expires_at'] && $entry['expires_at'] < time() ) { + continue; + } + $row = [ 'post_id' => $post_id, 'meta_key' => $meta_key, 'meta_value' => $entry['value'] ]; + $result[] = $output === ARRAY_A ? $row : (object) $row; + } + } + return $result; + } + + public function insert( string $table, array $data, $format = null ): int|false { + ListingStatsTest::$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, $f1 = null, $f2 = null ): int|false { + // Scan store and update matching entries (where clause uses id=1 placeholder). + foreach ( ListingStatsTest::$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( ListingStatsTest::$store[ $post_id ][ $meta_key ] ); + } elseif ( $post_id ) { + unset( ListingStatsTest::$store[ $post_id ] ); + } + return 1; + } + + public function query( string $sql ): int|bool { + $store = &ListingStatsTest::$store; + $flat = preg_replace( '/\s+/', ' ', $sql ); + + // Atomic postmeta increment — from flush_views_to_postmeta (P1-22). + // SQL: UPDATE `wp_postmeta` SET meta_value = meta_value + N WHERE post_id = P AND meta_key = 'hp_view_count' + if ( stripos( $flat, 'meta_value = meta_value +' ) !== false ) { + if ( preg_match( '/meta_value = meta_value \+ (\d+).*post_id = (\d+)/i', $flat, $m ) ) { + $by = (int) $m[1]; + $post_id = (int) $m[2]; + if ( isset( $GLOBALS['_wp_postmeta'][ $post_id ]['hp_view_count'] ) ) { + $GLOBALS['_wp_postmeta'][ $post_id ]['hp_view_count'] += $by; + return 1; + } + } + return 0; // no existing row — caller will add_post_meta + } + + // INSERT ... ON DUPLICATE KEY UPDATE (from set() via TMDO_DB::upsert()) + if ( stripos( $flat, 'ON DUPLICATE KEY' ) !== false && stripos( $flat, 'COALESCE' ) === false ) { + if ( preg_match( "/VALUES \((\d+), '([^']+)', '([^']*)', (NULL|'[^']+'), '[^']+'\)/i", $flat, $m ) ) { + $post_id = (int) $m[1]; + $meta_key = $m[2]; + $meta_value = $m[3]; + $expires_at = $m[4] === 'NULL' ? null : strtotime( trim( $m[4], "'" ) ); + $store[ $post_id ][ $meta_key ] = [ + 'value' => $meta_value, + 'expires_at' => $expires_at, + ]; + return 1; + } + } + + // INSERT ... ON DUPLICATE KEY UPDATE meta_value = CAST(COALESCE...) + N (from increment()) + if ( stripos( $flat, 'COALESCE' ) !== false ) { + if ( preg_match( "/VALUES \((\d+), '([^']+)', (\d+),/i", $flat, $m ) ) { + $post_id = (int) $m[1]; + $meta_key = $m[2]; + $by = (int) $m[3]; + $current = (int) ( $store[ $post_id ][ $meta_key ]['value'] ?? 0 ); + $store[ $post_id ][ $meta_key ] = [ + 'value' => (string) ( $current + $by ), + 'expires_at' => null, + ]; + return 1; + } + } + + return 0; + } + }; + } + + // ── increment_view ─────────────────────────────────────────────────────── + + public function test_increment_view_starts_at_one(): void { + TMDO_Listing_Stats::increment_view( 10 ); + $this->assertSame( '1', TMDO_Zone_Warm::get( 10, TMDO_Listing_Stats::VIEW_KEY ) ); + } + + public function test_increment_view_accumulates(): void { + TMDO_Listing_Stats::increment_view( 10 ); + TMDO_Listing_Stats::increment_view( 10 ); + TMDO_Listing_Stats::increment_view( 10 ); + $this->assertSame( '3', TMDO_Zone_Warm::get( 10, TMDO_Listing_Stats::VIEW_KEY ) ); + } + + // ── get_view_count ─────────────────────────────────────────────────────── + + public function test_get_view_count_reads_warm_zone_first(): void { + TMDO_Listing_Stats::increment_view( 20 ); + TMDO_Listing_Stats::increment_view( 20 ); + + // postmeta has 0 (default), warm has 2. + $this->assertSame( 2, TMDO_Listing_Stats::get_view_count( 20 ) ); + } + + public function test_get_view_count_falls_back_to_postmeta(): void { + // No warm entry → falls back to postmeta. + $GLOBALS['_wp_postmeta'][30]['hp_view_count'] = 99; + $this->assertSame( 99, TMDO_Listing_Stats::get_view_count( 30 ) ); + } + + public function test_get_view_count_returns_zero_when_empty(): void { + $this->assertSame( 0, TMDO_Listing_Stats::get_view_count( 999 ) ); + } + + // ── flush_views_to_postmeta ────────────────────────────────────────────── + + public function test_flush_moves_count_to_postmeta(): void { + TMDO_Listing_Stats::increment_view( 40 ); + TMDO_Listing_Stats::increment_view( 40 ); + + $flushed = TMDO_Listing_Stats::flush_views_to_postmeta(); + + $this->assertSame( 1, $flushed ); + $this->assertSame( 2, (int) ( $GLOBALS['_wp_postmeta'][40]['hp_view_count'] ?? 0 ) ); + } + + public function test_flush_clears_warm_entry(): void { + TMDO_Listing_Stats::increment_view( 50 ); + TMDO_Listing_Stats::flush_views_to_postmeta(); + + $this->assertNull( TMDO_Zone_Warm::get( 50, TMDO_Listing_Stats::VIEW_KEY ) ); + } + + public function test_flush_returns_zero_when_no_warm_entries(): void { + $this->assertSame( 0, TMDO_Listing_Stats::flush_views_to_postmeta() ); + } + + public function test_flush_accumulates_on_top_of_existing_postmeta(): void { + $GLOBALS['_wp_postmeta'][60]['hp_view_count'] = 10; + TMDO_Listing_Stats::increment_view( 60 ); + TMDO_Listing_Stats::increment_view( 60 ); + TMDO_Listing_Stats::flush_views_to_postmeta(); + + $this->assertSame( 12, (int) ( $GLOBALS['_wp_postmeta'][60]['hp_view_count'] ?? 0 ) ); + } +}