diff --git a/includes/class-tmdo-hivepress-entity-groups.php b/includes/class-tmdo-hivepress-entity-groups.php index 184c367..42a78f8 100644 --- a/includes/class-tmdo-hivepress-entity-groups.php +++ b/includes/class-tmdo-hivepress-entity-groups.php @@ -280,17 +280,25 @@ final class TMDO_HivePress_Entity_Groups { * They get a group of their own — a new table, each column indexed at * creation — so hp_listing_core and its pinned definition stay unchanged. * Keys another group already manages, keys without the hp_ prefix and - * unknown types are ignored. Backfill with `wp tmdo post-migrate-group`. + * types that cannot take a plain index (json, binary) are ignored. Core + * fills the new table from native meta (drift fix) before using it. + * + * Registered once themes are loaded (the init:1 run): a filter added in a + * theme is seen, and the group gets one definition per request. * * @return void */ private static function register_hp_listing_attributes_group(): void { + if ( ! did_action( 'after_setup_theme' ) ) { + return; + } $fields = array(); foreach ( (array) apply_filters( 'tmdo_hp_listing_attribute_fields', array() ) as $key => $type ) { - $key = (string) $key; + $key = (string) $key; + $owner = TMDO_Entity_Registry::get_field( 'post', $key, 'hp_listing' ); if ( 1 !== preg_match( '/^hp_[a-z0-9_]+$/', $key ) - || ! in_array( $type, TMDO_Entity_Registry::VALID_TYPES, true ) - || null !== TMDO_Entity_Registry::get_field( 'post', $key, 'hp_listing' ) ) { + || ! in_array( $type, array( 'text', 'textarea', 'integer', 'decimal', 'boolean', 'date', 'datetime', 'timestamp', 'enum' ), true ) + || ( null !== $owner && 'hp_listing_attrs' !== $owner['group'] ) ) { continue; } $fields[] = array( diff --git a/includes/class-tmdo-hivepress-transient-filter.php b/includes/class-tmdo-hivepress-transient-filter.php index bb17a46..85a681a 100644 --- a/includes/class-tmdo-hivepress-transient-filter.php +++ b/includes/class-tmdo-hivepress-transient-filter.php @@ -270,9 +270,10 @@ final class TMDO_Hivepress_Transient_Filter { * In wp_options both halves of the transient pair are written with * `autoload=no` (they are read on-demand only — never via * wp_load_alloptions() — so eagerly loading them on every page would defeat - * the point of the move). In the object cache a timeout entry expires when - * it says; the value, written before its timeout, after MAX_CACHE_TTL — a - * miss only makes HivePress rebuild it — so entries of deleted posts do + * the point of the move). In the object cache the value, written before its + * timeout, expires after MAX_CACHE_TTL (a miss only makes HivePress rebuild + * it) and the timeout a minute after the later of that and its own time, + * so HivePress always sees it expire first; entries of deleted posts do * not pile up (2.3 PR-099). * * @param int $post_id Post ID. @@ -286,7 +287,9 @@ final class TMDO_Hivepress_Transient_Filter { $option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; if ( wp_using_ext_object_cache() ) { - $ttl = $is_timeout ? min( self::MAX_CACHE_TTL, max( 1, (int) $meta_value - time() ) ) : self::MAX_CACHE_TTL; + // A timeout outlives its value: HivePress takes a missing timeout for + // "never expires" and would keep serving the value. + $ttl = $is_timeout ? max( self::MAX_CACHE_TTL, (int) $meta_value - time() ) + MINUTE_IN_SECONDS : self::MAX_CACHE_TTL; wp_cache_set( $option_name, $meta_value, self::CACHE_GROUP, $ttl ); return; } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c4a0cc2..05b4c67 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -24,6 +24,12 @@ if ( ! file_exists( $tmdo_core_bootstrap ) ) { } require_once $tmdo_core_bootstrap; +if ( ! function_exists( 'did_action' ) ) { + function did_action( string $hook ): int { + return (int) ( $GLOBALS['_wp_did_action'][ $hook ] ?? 0 ); + } +} + define( 'TMDO_HIVEPRESS_PATH', dirname( __DIR__ ) . '/' ); define( 'TMDO_HIVEPRESS_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-hivepress-addon/' ); define( 'TMDO_HIVEPRESS_FILE', dirname( __DIR__ ) . '/2meet-data-optimizer-hivepress-addon.php' ); diff --git a/tests/integration/HivepressTransientFilterTest.php b/tests/integration/HivepressTransientFilterTest.php index 3b70036..ada423f 100644 --- a/tests/integration/HivepressTransientFilterTest.php +++ b/tests/integration/HivepressTransientFilterTest.php @@ -214,9 +214,10 @@ class HivepressTransientFilterTest extends TestCase { } /** - * Entries expire in the object cache too (2.3 PR-099): the timeout at the - * time it records, the value (written first, before its timeout is known) - * after at most a week. Orphans of deleted posts no longer pile up. + * Entries expire in the object cache too (2.3 PR-099): the value (written + * first, before its timeout is known) after a week, 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 { $GLOBALS['_wp_using_ext_object_cache'] = true; @@ -229,8 +230,12 @@ class HivepressTransientFilterTest extends TestCase { $ttl = $GLOBALS['_wp_cache_ttl'][ WPDO_Hivepress_Transient_Filter::CACHE_GROUP ]; $this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL, $ttl[ '_transient_' . $translated ] ); - $this->assertEqualsWithDelta( 3600, $ttl[ '_transient_timeout_' . $translated ], 2 ); - $this->assertSame( 1, $ttl[ '_transient_timeout_' . WPDO_Hivepress_Transient_Filter::translate_key( 101, $key ) ], 'already expired' ); + // 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 { diff --git a/tests/unit/HivePressEntityGroupsTest.php b/tests/unit/HivePressEntityGroupsTest.php index 44c170f..e004493 100644 --- a/tests/unit/HivePressEntityGroupsTest.php +++ b/tests/unit/HivePressEntityGroupsTest.php @@ -88,8 +88,10 @@ class HivePressEntityGroupsTest extends TestCase { 'hp_price' => 'decimal', // Already in hp_listing_core. 'warranty' => 'integer', // Not a HivePress key. 'hp_color' => 'colour', // Unknown type. + 'hp_features' => 'json', // No plain index on LONGTEXT. ); add_filter( 'tmdo_hp_listing_attribute_fields', $fields ); + $GLOBALS['_wp_did_action']['after_setup_theme'] = 1; try { TMDO_Entity_Registry::init(); TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) ); @@ -97,6 +99,7 @@ class HivePressEntityGroupsTest extends TestCase { TMDO_HivePress_Entity_Groups::register_entity_fields(); } finally { $GLOBALS['_wp_filter_callbacks']['tmdo_hp_listing_attribute_fields'] = array(); + unset( $GLOBALS['_wp_did_action'] ); } $this->assertSame( array( 'hp_warranty' ), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_attrs' ) ); @@ -106,6 +109,31 @@ class HivePressEntityGroupsTest extends TestCase { $this->test_definitions_match_persisted_schema_hashes(); } + /** + * Registration runs twice (plugins_loaded, then init:1): the group is + * registered on the run after themes load, so a theme's filter counts and + * a filter that changed between runs cannot collide (review F8). + */ + public function test_site_listing_attributes_are_registered_once_themes_are_loaded(): void { + add_filter( 'tmdo_hp_listing_attribute_fields', static fn() => array( 'hp_warranty' => 'integer' ) ); + try { + TMDO_Entity_Registry::init(); + TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) ); + TMDO_Entity_Registry::register_adapter( 'user', $this->createMock( TMDO_Entity_Adapter_Interface::class ) ); + TMDO_HivePress_Entity_Groups::register_entity_fields(); + $this->assertSame( array(), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_attrs' ), 'plugins_loaded run: not yet' ); + + $GLOBALS['_wp_did_action']['after_setup_theme'] = 1; + TMDO_HivePress_Entity_Groups::register_entity_fields(); + } finally { + $GLOBALS['_wp_filter_callbacks']['tmdo_hp_listing_attribute_fields'] = array(); + unset( $GLOBALS['_wp_did_action'] ); + } + + $this->assertSame( array( 'hp_warranty' ), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_attrs' ) ); + $this->assertSame( array(), TMDO_Entity_Registry::get_collisions() ); + } + public function test_shared_keys_keep_their_current_owner(): void { foreach ( self::SHARED_KEY_OWNERS as $key => $group ) { $this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key )['group'], $key );