From 9ed93f4bb0b33bd04fe1a3c66e04a38f991873a9 Mon Sep 17 00:00:00 2001 From: salvatorecorsi <86927009+salvatorecorsi@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:57:35 +0200 Subject: [PATCH 1/2] Tests: Preserve registered metadata between methods and classes --- tests/phpunit/includes/abstract-testcase.php | 78 ++++++- .../phpunit/tests/includes/registeredMeta.php | 218 ++++++++++++++++++ tests/phpunit/tests/meta/registerMeta.php | 7 + tests/phpunit/tests/post/meta.php | 7 + tests/phpunit/tests/term/meta.php | 3 + 5 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 tests/phpunit/tests/includes/registeredMeta.php diff --git a/tests/phpunit/includes/abstract-testcase.php b/tests/phpunit/includes/abstract-testcase.php index aaf0c8aa57b5e..4b343666819db 100644 --- a/tests/phpunit/includes/abstract-testcase.php +++ b/tests/phpunit/includes/abstract-testcase.php @@ -32,6 +32,20 @@ abstract class WP_UnitTestCase_Base extends PHPUnit_Adapter_TestCase { protected static $hooks_saved = array(); protected static $ignore_files; + /** + * Metadata state before each class's fixtures are created, keyed by class. + * + * @var array[] + */ + private static $meta_before_class = array(); + + /** + * Metadata state before the current test, or null when setup was bypassed. + * + * @var array|null + */ + private $original_meta_state; + /** * The value of $GLOBALS['locale'] before each test, or null if it was unset. * @@ -95,7 +109,8 @@ public static function set_up_before_class() { $wpdb->db_connect(); ini_set( 'display_errors', 1 ); - $class = get_called_class(); + $class = get_called_class(); + self::$meta_before_class[ $class ] = self::backup_meta_state(); if ( method_exists( $class, 'wpSetUpBeforeClass' ) ) { call_user_func( array( $class, 'wpSetUpBeforeClass' ), static::factory() ); @@ -114,6 +129,11 @@ public static function tear_down_after_class() { call_user_func( array( $class, 'wpTearDownAfterClass' ) ); } + if ( isset( self::$meta_before_class[ $class ] ) ) { + self::restore_meta_state( self::$meta_before_class[ $class ] ); + unset( self::$meta_before_class[ $class ] ); + } + _delete_all_data(); self::flush_cache(); @@ -142,6 +162,8 @@ public function set_up() { $this->_backup_hooks(); } + $this->original_meta_state = self::backup_meta_state(); + $this->clean_up_global_scope(); $this->original_locale = $GLOBALS['locale'] ?? null; @@ -268,6 +290,9 @@ public function tear_down() { remove_filter( 'query', array( $this, '_drop_temporary_tables' ) ); remove_filter( 'wp_die_handler', array( $this, 'get_wp_die_handler' ) ); $this->_restore_hooks(); + if ( null !== $this->original_meta_state ) { + self::restore_meta_state( $this->original_meta_state ); + } wp_set_current_user( 0 ); // Restore the locale captured in set_up(); skip it for tests that bypass parent::set_up(). @@ -512,6 +537,57 @@ public static function flush_cache() { wp_cache_add_non_persistent_groups( array( 'counts', 'plugins', 'theme_json' ) ); } + /** + * Captures registrations and their hooks without re-registering metadata. + * + * The hooks also include legacy registrations without a registry entry and + * callbacks added independently of register_meta(). + * + * @return array Metadata registry and cloned metadata hooks. + */ + private static function backup_meta_state() { + $state = array( + 'keys' => $GLOBALS['wp_meta_keys'] ?? array(), + 'hooks' => array(), + ); + + foreach ( $GLOBALS['wp_filter'] as $name => $hook ) { + if ( self::is_meta_hook( $name ) ) { + $state['hooks'][ $name ] = clone $hook; + } + } + + return $state; + } + + /** + * Restores metadata hooks after the ordinary hook baseline has been restored. + * + * @param array $state Metadata state captured before the test or class. + */ + private static function restore_meta_state( $state ) { + $GLOBALS['wp_meta_keys'] = $state['keys']; + + foreach ( array_keys( $GLOBALS['wp_filter'] ) as $name ) { + if ( self::is_meta_hook( $name ) ) { + unset( $GLOBALS['wp_filter'][ $name ] ); + } + } + foreach ( $state['hooks'] as $name => $hook ) { + $GLOBALS['wp_filter'][ $name ] = clone $hook; + } + } + + /** + * Identifies the dynamic hooks used by metadata registrations and defaults. + * + * @param string $name Hook name. + * @return bool Whether the hook belongs to the metadata state. + */ + private static function is_meta_hook( $name ) { + return (bool) preg_match( '/^(?:sanitize|auth)_.+_meta_|^default_.+_metadata$/', $name ); + } + /** * Cleans up any registered meta keys. * diff --git a/tests/phpunit/tests/includes/registeredMeta.php b/tests/phpunit/tests/includes/registeredMeta.php new file mode 100644 index 0000000000000..bb245e2f4c371 --- /dev/null +++ b/tests/phpunit/tests/includes/registeredMeta.php @@ -0,0 +1,218 @@ + $object_subtype, + 'single' => true, + 'default' => 'Original default', + 'sanitize_callback' => 'sanitize_text_field', + 'auth_callback' => '__return_true', + ); + register_meta( $object_type, $key, $args ); + $expected = get_registered_meta_keys( $object_type, $object_subtype )[ $key ]; + $case = $this->create_test_case(); + $class = get_class( $case ); + $class::set_up_before_class(); + + try { + $case->set_up(); + try { + if ( $unregister ) { + unregister_meta_key( $object_type, $key, $object_subtype ); + } else { + register_meta( + $object_type, + $key, + array_merge( + $args, + array( + 'default' => 'Changed default', + 'auth_callback' => '__return_false', + ) + ) + ); + } + register_meta( $object_type, '_test_temporary_meta', $args ); + } finally { + $case->tear_down(); + } + + $next_case = new $class( 'test_second' ); + $next_case->set_up(); + try { + $registered = get_registered_meta_keys( $object_type, $object_subtype ); + $this->assertArrayHasKey( $key, $registered ); + $this->assertSame( $expected, $registered[ $key ] ); + $this->assertSame( 'Example', sanitize_meta( $key, 'Example', $object_type, $object_subtype ) ); + $hook = "auth_{$object_type}_meta_{$key}"; + if ( $object_subtype ) { + $hook .= "_for_{$object_subtype}"; + } + $this->assertTrue( apply_filters( $hook, false, $key, 1, 1, 'edit_post_meta', array() ) ); + $this->assertFalse( registered_meta_key_exists( $object_type, '_test_temporary_meta', $object_subtype ) ); + } finally { + $next_case->tear_down(); + } + } finally { + $class::tear_down_after_class(); + } + } + + /** + * @return array[] + */ + public static function data_registered_metadata() { + $cases = array(); + foreach ( array( + 'post' => '', + 'post subtype' => 'page', + 'term subtype' => 'category', + 'user' => '', + 'comment' => '', + ) as $label => $subtype ) { + $type = explode( ' ', $label )[0]; + foreach ( array( false, true ) as $unregister ) { + $cases[ $label . ( $unregister ? ' removed' : ' replaced' ) ] = array( $type, $subtype, $unregister ); + } + } + return $cases; + } + + public function test_restores_class_registrations_and_their_callbacks() { + // Capture the ordinary hook baseline before the class fixture exists. + $previous_case = $this->create_test_case(); + $previous_case->set_up(); + $previous_case->tear_down(); + + register_post_meta( 'post', '_test_plugin_meta', array( 'default' => 'Plugin default' ) ); + $case = new class( 'test_first' ) extends WP_UnitTestCase { + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + register_post_meta( + 'post', + '_test_class_meta', + array( + 'default' => 'Class default', + 'sanitize_callback' => 'sanitize_text_field', + 'auth_callback' => '__return_true', + ) + ); + register_post_meta( 'post', '_test_plugin_meta', array( 'default' => 'Class override' ) ); + // Old-style registrations have callbacks but no registry entry. + register_meta( 'post', '_test_legacy_meta', 'sanitize_text_field' ); + } + + public function test_first() {} + public function test_second() {} + }; + $class = get_class( $case ); + $class::set_up_before_class(); + + try { + foreach ( array( $case, new $class( 'test_second' ) ) as $test_case ) { + $test_case->set_up(); + try { + $registered = get_registered_meta_keys( 'post', 'post' ); + $this->assertArrayHasKey( '_test_class_meta', $registered ); + $this->assertArrayHasKey( '_test_plugin_meta', $registered ); + $this->assertSame( 'Class default', $registered['_test_class_meta']['default'] ); + $this->assertSame( 'Class override', $registered['_test_plugin_meta']['default'] ); + $this->assertSame( 'Example', sanitize_meta( '_test_class_meta', 'Example', 'post', 'post' ) ); + $this->assertSame( 'Example', sanitize_meta( '_test_legacy_meta', 'Example', 'post' ) ); + unregister_post_meta( 'post', '_test_class_meta' ); + remove_filter( 'sanitize_post_meta__test_legacy_meta', 'sanitize_text_field' ); + } finally { + $test_case->tear_down(); + } + } + } finally { + $class::tear_down_after_class(); + } + + $this->assertFalse( registered_meta_key_exists( 'post', '_test_class_meta', 'post' ) ); + $registered = get_registered_meta_keys( 'post', 'post' ); + $this->assertArrayHasKey( '_test_plugin_meta', $registered ); + $this->assertSame( 'Plugin default', $registered['_test_plugin_meta']['default'] ); + $this->assertSame( 'Example', sanitize_meta( '_test_legacy_meta', 'Example', 'post' ) ); + $this->assertFalse( has_filter( 'sanitize_post_meta__test_class_meta_for_post' ) ); + } + + public function test_restores_defaults_sanitization_and_authorization_for_real_posts() { + register_post_meta( + 'post', + '_test_post_meta', + array( + 'single' => true, + 'default' => 'Original default', + 'sanitize_callback' => 'sanitize_text_field', + 'auth_callback' => '__return_true', + ) + ); + $case = new class( 'test_first' ) extends WP_UnitTestCase { + public static $author_id; + public static $post_id; + + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$author_id = $factory->user->create( array( 'role' => 'author' ) ); + self::$post_id = $factory->post->create( array( 'post_author' => self::$author_id ) ); + } + + public function test_first() {} + public function test_second() {} + }; + $class = get_class( $case ); + $class::set_up_before_class(); + + try { + foreach ( array( $case, new $class( 'test_second' ) ) as $test_case ) { + $test_case->set_up(); + try { + wp_set_current_user( $class::$author_id ); + $this->assertTrue( current_user_can( 'edit_post_meta', $class::$post_id, '_test_post_meta' ) ); + $this->assertSame( 'Original default', get_post_meta( $class::$post_id, '_test_post_meta', true ) ); + update_post_meta( $class::$post_id, '_test_post_meta', 'Example' ); + $this->assertSame( 'Example', get_post_meta( $class::$post_id, '_test_post_meta', true ) ); + register_post_meta( 'post', '_test_post_meta', array( 'auth_callback' => '__return_false' ) ); + $this->assertFalse( current_user_can( 'edit_post_meta', $class::$post_id, '_test_post_meta' ) ); + } finally { + $test_case->tear_down(); + } + } + } finally { + $class::tear_down_after_class(); + } + } + + /** + * Creates a test case with valid method names for the real setup routines. + * + * @return WP_UnitTestCase + */ + private function create_test_case() { + return new class( 'test_first' ) extends WP_UnitTestCase { + public function test_first() {} + public function test_second() {} + }; + } +} diff --git a/tests/phpunit/tests/meta/registerMeta.php b/tests/phpunit/tests/meta/registerMeta.php index 16655124c95ca..1b3ad3113095b 100644 --- a/tests/phpunit/tests/meta/registerMeta.php +++ b/tests/phpunit/tests/meta/registerMeta.php @@ -10,6 +10,13 @@ class Tests_Meta_Register_Meta extends WP_UnitTestCase { protected static $user_id; protected static $blog_id; + public function set_up() { + parent::set_up(); + + // These tests inspect the complete registry, independently of bootstrap registrations. + $this->unregister_all_meta_keys(); + } + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$post_id = $factory->post->create( array( 'post_type' => 'page' ) ); self::$term_id = $factory->term->create( array( 'taxonomy' => 'category' ) ); diff --git a/tests/phpunit/tests/post/meta.php b/tests/phpunit/tests/post/meta.php index 5642f44732e83..553dce4945b2a 100644 --- a/tests/phpunit/tests/post/meta.php +++ b/tests/phpunit/tests/post/meta.php @@ -16,6 +16,13 @@ class Tests_Post_Meta extends WP_UnitTestCase { protected static $post_id; protected static $post_id_2; + public function set_up() { + parent::set_up(); + + // These tests inspect the complete registry, independently of bootstrap registrations. + $this->unregister_all_meta_keys(); + } + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$author = $factory->user->create_and_get( array( 'role' => 'editor' ) ); diff --git a/tests/phpunit/tests/term/meta.php b/tests/phpunit/tests/term/meta.php index b4149d2dd9193..4774901657b40 100644 --- a/tests/phpunit/tests/term/meta.php +++ b/tests/phpunit/tests/term/meta.php @@ -15,6 +15,9 @@ class Tests_Term_Meta extends WP_UnitTestCase { public function set_up() { parent::set_up(); + + // These tests inspect the complete registry, independently of bootstrap registrations. + $this->unregister_all_meta_keys(); register_taxonomy( 'wptests_tax', 'post' ); } From 128bc4aea3649ff26187c220a1e2b2cacbb6b81d Mon Sep 17 00:00:00 2001 From: salvatorecorsi <86927009+salvatorecorsi@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:07:09 +0100 Subject: [PATCH 2/2] Tests: reduce isolated metadata lifecycle provider cases --- .../phpunit/tests/includes/registeredMeta.php | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/tests/phpunit/tests/includes/registeredMeta.php b/tests/phpunit/tests/includes/registeredMeta.php index bb245e2f4c371..53e6b3d23114a 100644 --- a/tests/phpunit/tests/includes/registeredMeta.php +++ b/tests/phpunit/tests/includes/registeredMeta.php @@ -84,20 +84,14 @@ public function test_restores_metadata_registered_before_test_methods( $object_t * @return array[] */ public static function data_registered_metadata() { - $cases = array(); - foreach ( array( - 'post' => '', - 'post subtype' => 'page', - 'term subtype' => 'category', - 'user' => '', - 'comment' => '', - ) as $label => $subtype ) { - $type = explode( ' ', $label )[0]; - foreach ( array( false, true ) as $unregister ) { - $cases[ $label . ( $unregister ? ' removed' : ' replaced' ) ] = array( $type, $subtype, $unregister ); - } - } - return $cases; + // Each case boots WordPress separately. Cover the scopes and mutation modes + // without repeating their full cross-product in isolated processes. + return array( + 'post replaced' => array( 'post', '', false ), + 'post subtype removed' => array( 'post', 'page', true ), + 'term subtype replaced' => array( 'term', 'category', false ), + 'user removed' => array( 'user', '', true ), + ); } public function test_restores_class_registrations_and_their_callbacks() {