query( 'CREATE TABLE IF NOT EXISTS `' . 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' ); } protected function setUp(): void { global $wpdb; $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' ); // Reset wp_options / object cache state for filter tests $GLOBALS['_wp_options'] = array(); $GLOBALS['_wp_cache'] = array(); $GLOBALS['_wp_using_ext_object_cache'] = false; // Default the toggle to enabled update_option( WPDO_Hivepress_Transient_Filter::OPT_ENABLED, '1' ); } // ── is_target_key() ───────────────────────────────────────────────────── public function test_is_target_key_matches_hp_transient_value(): void { $this->assertTrue( WPDO_Hivepress_Transient_Filter::is_target_key( '_transient_hp_models/listing_category/version' ) ); } public function test_is_target_key_matches_hp_transient_timeout(): void { $this->assertTrue( WPDO_Hivepress_Transient_Filter::is_target_key( '_transient_timeout_hp_models/term/listing_availability/version' ) ); } public function test_is_target_key_rejects_non_hp_transient(): void { $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_target_key( '_transient_other_plugin_cache' ) ); $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_target_key( '_transient_timeout_other' ) ); } public function test_is_target_key_rejects_regular_meta(): void { $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_target_key( 'hp_price' ) ); $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_target_key( '_thumbnail_id' ) ); } public function test_is_target_key_rejects_non_string(): void { $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_target_key( null ) ); $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_target_key( 123 ) ); $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_target_key( array() ) ); } // ── translate_key() ───────────────────────────────────────────────────── public function test_translate_key_namespaces_by_post_id(): void { $key1 = WPDO_Hivepress_Transient_Filter::translate_key( 100, '_transient_hp_models/cat/v1' ); $key2 = WPDO_Hivepress_Transient_Filter::translate_key( 200, '_transient_hp_models/cat/v1' ); $this->assertNotSame( $key1, $key2, 'Same meta_key on different posts must yield different translated keys.' ); $this->assertStringContainsString( 'wpdo_hp_pm_100_', $key1 ); $this->assertStringContainsString( 'wpdo_hp_pm_200_', $key2 ); } public function test_translate_key_strips_transient_prefix(): void { $value_key = WPDO_Hivepress_Transient_Filter::translate_key( 100, '_transient_hp_models/cat' ); $timeout_key = WPDO_Hivepress_Transient_Filter::translate_key( 100, '_transient_timeout_hp_models/cat' ); // Value and timeout SHOULD share the same translated suffix (md5 of stripped name) // since they refer to the same logical cache entry. $this->assertSame( $value_key, $timeout_key ); } public function test_translate_key_md5_handles_long_input(): void { $long_key = '_transient_hp_models/term/listing_availability/' . str_repeat( 'a', 200 ); $translated = WPDO_Hivepress_Transient_Filter::translate_key( 1, $long_key ); // Result must fit within wp_options.option_name (172 chars used as proxy). $option_name = '_transient_timeout_' . $translated; $this->assertLessThanOrEqual( 191, strlen( $option_name ), 'Translated option_name must fit MySQL VARCHAR(191).' ); } // ── on_read() callback ────────────────────────────────────────────────── public function test_on_read_returns_pre_for_non_target_key(): void { $result = WPDO_Hivepress_Transient_Filter::on_read( null, 100, '_thumbnail_id', true ); $this->assertNull( $result, 'Non-target keys must pass through (return $pre).' ); } public function test_on_read_returns_value_from_wp_options_when_present(): void { // Seed a "translated" wp_options entry as if filter previously wrote it. $key = '_transient_hp_models/listing_category/version'; $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); update_option( '_transient_' . $translated, 'cached-value-xyz' ); $result = WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ); $this->assertSame( array( 'cached-value-xyz' ), $result ); } public function test_on_read_returns_pre_on_cache_miss(): void { // No wp_options entry seeded. $result = WPDO_Hivepress_Transient_Filter::on_read( null, 100, '_transient_hp_models/cat/v1', true ); $this->assertNull( $result, 'Cache miss should fall through (return $pre = null).' ); } // ── on_add() / on_update() callbacks ──────────────────────────────────── public function test_on_add_writes_to_wp_options_and_short_circuits(): void { $key = '_transient_hp_models/cat/v1'; $result = WPDO_Hivepress_Transient_Filter::on_add( null, 100, $key, 'value-xyz', false ); $this->assertTrue( $result, 'Filter must short-circuit (return truthy).' ); $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); $this->assertSame( 'value-xyz', get_option( '_transient_' . $translated ) ); } public function test_on_update_writes_to_wp_options(): void { $key = '_transient_hp_models/cat/v1'; WPDO_Hivepress_Transient_Filter::on_update( null, 200, $key, 'updated-value', '' ); $translated = WPDO_Hivepress_Transient_Filter::translate_key( 200, $key ); $this->assertSame( 'updated-value', get_option( '_transient_' . $translated ) ); } public function test_on_update_handles_timeout_separately(): void { $value_key = '_transient_hp_models/cat/v1'; $timeout_key = '_transient_timeout_hp_models/cat/v1'; WPDO_Hivepress_Transient_Filter::on_update( null, 100, $value_key, 'val', '' ); WPDO_Hivepress_Transient_Filter::on_update( null, 100, $timeout_key, '99999999', '' ); $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $value_key ); $this->assertSame( 'val', get_option( '_transient_' . $translated ) ); $this->assertSame( '99999999', get_option( '_transient_timeout_' . $translated ) ); } public function test_on_add_passes_through_non_target_keys(): void { $result = WPDO_Hivepress_Transient_Filter::on_add( null, 100, '_thumbnail_id', '50', false ); $this->assertNull( $result ); // And nothing was written to wp_options $this->assertFalse( get_option( '_transient_wpdo_hp_pm_100_' . md5( '' ) ) ); } // ── on_delete() callback ──────────────────────────────────────────────── public function test_on_delete_clears_wp_options_entry(): void { $key = '_transient_hp_models/cat/v1'; WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'value', '' ); $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); $this->assertSame( 'value', get_option( '_transient_' . $translated ) ); WPDO_Hivepress_Transient_Filter::on_delete( null, 100, $key, '', false ); $this->assertFalse( get_option( '_transient_' . $translated ) ); } public function test_on_delete_passes_through_non_target_keys(): void { $result = WPDO_Hivepress_Transient_Filter::on_delete( null, 100, 'hp_price', '', false ); $this->assertNull( $result ); } // ── Round-trip: write then read ───────────────────────────────────────── public function test_full_round_trip_write_then_read(): void { $key = '_transient_hp_models/listing_category/abc123'; WPDO_Hivepress_Transient_Filter::on_update( null, 500, $key, array( 'serialized', 'data' ), '' ); $value = WPDO_Hivepress_Transient_Filter::on_read( null, 500, $key, true ); $this->assertSame( array( array( 'serialized', 'data' ) ), $value ); } // ── External object cache (2.2 PR-081) ────────────────────────────────── public function test_with_an_object_cache_writes_skip_wp_options(): void { $GLOBALS['_wp_using_ext_object_cache'] = true; $key = '_transient_hp_models/cat/v1'; $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); $this->assertTrue( WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' ) ); WPDO_Hivepress_Transient_Filter::on_update( null, 100, '_transient_timeout_hp_models/cat/v1', '99999999', '' ); $this->assertFalse( get_option( '_transient_' . $translated ), 'no wp_options row' ); $this->assertFalse( get_option( '_transient_timeout_' . $translated ), 'no wp_options row' ); $this->assertSame( array( 'val' ), WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) ); $this->assertSame( array( '99999999' ), WPDO_Hivepress_Transient_Filter::on_read( null, 100, '_transient_timeout_hp_models/cat/v1', true ) ); } /** * Entries expire in the object cache too (2.3 PR-099): the value (written * first, before its timeout is known) after a day, the timeout after the * later of that and its own time, plus a minute. Orphans of deleted posts * no longer pile up. */ public function test_with_an_object_cache_entries_get_a_ttl(): void { // A day, HivePress's own default: at a week, 1.3M listings kept 4.7M // entries (1 GB) in a shared Redis and pushed other sites' keys out (2.4 PR-120). $this->assertSame( 86400, WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL ); $GLOBALS['_wp_using_ext_object_cache'] = true; $key = '_transient_hp_models/cat/v1'; $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' ); WPDO_Hivepress_Transient_Filter::on_update( null, 100, '_transient_timeout_hp_models/cat/v1', (string) ( time() + 3600 ), '' ); WPDO_Hivepress_Transient_Filter::on_update( null, 101, '_transient_timeout_hp_models/cat/v1', (string) ( time() - 5 ), '' ); $ttl = $GLOBALS['_wp_cache_ttl'][ WPDO_Hivepress_Transient_Filter::CACHE_GROUP ]; $this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL, $ttl[ '_transient_' . $translated ] ); // A timeout must outlive its value: HivePress takes a missing timeout for // "never expires" and would serve the stale value (review F5). $this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL + 60, $ttl[ '_transient_timeout_' . $translated ] ); $this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL + 60, $ttl[ '_transient_timeout_' . WPDO_Hivepress_Transient_Filter::translate_key( 101, $key ) ], 'already expired' ); WPDO_Hivepress_Transient_Filter::on_update( null, 102, '_transient_timeout_hp_models/cat/v1', (string) ( time() + 2 * WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL ), '' ); $this->assertEqualsWithDelta( 2 * WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL + 60, $GLOBALS['_wp_cache_ttl'][ WPDO_Hivepress_Transient_Filter::CACHE_GROUP ][ '_transient_timeout_' . WPDO_Hivepress_Transient_Filter::translate_key( 102, $key ) ], 2 ); } public function test_with_an_object_cache_a_value_left_in_wp_options_is_not_read(): void { // A miss must not fall back to get_option(): the option is absent after // cleanup and WordPress would add its name to `notoptions` for good. $key = '_transient_hp_models/cat/v1'; $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); update_option( '_transient_' . $translated, 'from-before' ); $GLOBALS['_wp_using_ext_object_cache'] = true; $this->assertNull( WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) ); } public function test_with_an_object_cache_delete_leaves_wp_options_alone(): void { $key = '_transient_hp_models/cat/v1'; $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); update_option( '_transient_' . $translated, 'from-before' ); $GLOBALS['_wp_using_ext_object_cache'] = true; WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' ); WPDO_Hivepress_Transient_Filter::on_delete( null, 100, $key, '', false ); $this->assertNull( WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) ); $this->assertSame( 'from-before', get_option( '_transient_' . $translated ), 'left for cleanup-hp-transients --options' ); } // ── count/purge legacy ────────────────────────────────────────────────── public function test_count_legacy_postmeta_rows_returns_zero_when_clean(): void { $this->assertSame( 0, WPDO_Hivepress_Transient_Filter::count_legacy_postmeta_rows() ); } public function test_count_legacy_postmeta_rows_counts_hp_transients_only(): void { global $wpdb; // Insert a mix of rows: target hp transients + non-target rows. $rows = array( array( 1, '_transient_hp_models/cat/v1', 'val1' ), array( 1, '_transient_timeout_hp_models/cat/v1', '99999' ), array( 2, '_transient_hp_models/tag/v2', 'val2' ), array( 1, 'hp_price', '100' ), array( 1, '_thumbnail_id', '50' ), array( 2, '_transient_other_plugin', 'foreign' ), // non-hp transient ); foreach ( $rows as $r ) { $wpdb->insert( self::POSTMETA, array( 'post_id' => $r[0], 'meta_key' => $r[1], 'meta_value' => $r[2], ) ); } $count = WPDO_Hivepress_Transient_Filter::count_legacy_postmeta_rows(); $this->assertSame( 3, $count, 'Must count only _transient_hp_* rows, not other-plugin transients or normal meta.' ); } public function test_purge_legacy_postmeta_rows_deletes_only_hp_transients(): void { global $wpdb; $wpdb->insert( self::POSTMETA, array( 'post_id' => 1, 'meta_key' => '_transient_hp_models/cat/v1', 'meta_value' => 'a' ) ); $wpdb->insert( self::POSTMETA, array( 'post_id' => 1, 'meta_key' => '_transient_timeout_hp_models/cat/v1', 'meta_value' => '99' ) ); $wpdb->insert( self::POSTMETA, array( 'post_id' => 1, 'meta_key' => 'hp_price', 'meta_value' => '50' ) ); $wpdb->insert( self::POSTMETA, array( 'post_id' => 2, 'meta_key' => '_transient_woo_thing', 'meta_value' => 'foreign' ) ); $deleted = WPDO_Hivepress_Transient_Filter::purge_legacy_postmeta_rows(); $this->assertSame( 2, $deleted ); $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::POSTMETA . '`' ); $this->assertSame( 2, $remaining, 'hp_price + _transient_woo_thing must remain.' ); } // ── is_enabled() toggle ───────────────────────────────────────────────── public function test_is_enabled_defaults_true(): void { delete_option( WPDO_Hivepress_Transient_Filter::OPT_ENABLED ); $this->assertTrue( WPDO_Hivepress_Transient_Filter::is_enabled() ); } public function test_is_enabled_respects_zero_value(): void { update_option( WPDO_Hivepress_Transient_Filter::OPT_ENABLED, '0' ); $this->assertFalse( WPDO_Hivepress_Transient_Filter::is_enabled() ); } }