Files
2meet-data-optimizer-hivepr…/tests/integration/HivepressTransientFilterTest.php
T
wpdev 788ddccfb3
Tests / PHP Lint (pull_request) Successful in 10s
Tests / Unit Tests (pull_request) Successful in 1m2s
Tests / Integration Tests (pull_request) Successful in 1m53s
fix(2meet-data-optimizer-hivepress-addon): with an object cache, keep HivePress transients out of wp_options entirely
- On a cache miss the filter fell back to get_option(): with the values now written to the cache, the option is absent, so WordPress added its name to `notoptions` for good (dev36: +1,000 entries for 1,000 posts, one array loaded on every miss); a hit kept the old row in the options cache group with no expiry, which slowed the selective Redis flush (8.9 s, BUSY). The cache is now the only store for reads and deletes; old rows wait for cleanup-hp-transients --options. Without an object cache nothing changes.
- Verify: HivepressTransientFilterTest (2 RED before); dev36 notoptions 210 → 210 over 1,000 posts (was 128 → 1,128)
- PR: PR-089c
2026-09-27 10:32:12 +08:00

295 lines
14 KiB
PHP

<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration test: WPDO_Hivepress_Transient_Filter (v2.11.5).
*
* Verifies the filter contract:
* - is_target_key() correctly identifies _transient_hp_* / _transient_timeout_hp_*
* - non-target keys (regular meta, foreign-prefix transients) are NOT touched
* - translate_key() namespaces by post_id and md5-hashes the key
* - count/purge legacy SQL helpers correctly target HivePress transient rows only
* - register/load chain doesn't cause syntax/load failures
*
* Filter callbacks (on_read/on_add/on_update/on_delete) are called via the
* WordPress metadata filter chain. Since the test bootstrap stubs
* `update_post_meta` / `get_post_meta` to bypass the filter chain entirely
* (they go directly to the test wp_itest_postmeta table or $GLOBALS), we
* test the callback functions directly with synthetic filter args.
*/
class HivepressTransientFilterTest extends TestCase {
private const POSTMETA = 'wp_itest_postmeta';
public static function setUpBeforeClass(): void {
global $wpdb;
// Ensure the postmeta table exists for purge SQL tests.
$wpdb->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 ) );
}
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() );
}
}