diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php new file mode 100644 index 0000000000000..bb7b22ebdf2fc --- /dev/null +++ b/src/wp-includes/class-wp-comment-type.php @@ -0,0 +1,240 @@ +name = $comment_type; + + $this->set_props( $args ); + } + + /** + * Sets comment type properties. + * + * See the register_comment_type() function for accepted arguments for `$args`. + * + * @since 7.2.0 + * + * @param array|string $args Array or string of arguments for registering a comment type. + */ + public function set_props( $args ) { + $args = wp_parse_args( $args ); + + /** + * Filters the arguments for registering a comment type. + * + * @since 7.2.0 + * + * @param array $args Array of arguments for registering a comment type. + * See the register_comment_type() function for accepted arguments. + * @param string $comment_type Comment type key. + */ + $args = apply_filters( 'register_comment_type_args', $args, $this->name ); + + $comment_type = $this->name; + + /** + * Filters the arguments for registering a specific comment type. + * + * The dynamic portion of the filter name, `$comment_type`, refers to the comment type key. + * + * Possible hook names include: + * + * - `register_comment_comment_type_args` + * - `register_pingback_comment_type_args` + * + * @since 7.2.0 + * + * @param array $args Array of arguments for registering a comment type. + * See the register_comment_type() function for accepted arguments. + * @param string $comment_type Comment type key. + */ + $args = apply_filters( "register_{$comment_type}_comment_type_args", $args, $this->name ); + + /* + * Note: 'label' is intentionally omitted from the defaults. Leaving the property + * unset (null) lets get_comment_type_labels() fall back to the default labels, the + * same way WP_Post_Type and WP_Taxonomy behave. A 'label' default of false would be + * treated as a provided value and overwrite the default name with false. + */ + $defaults = array( + 'labels' => array(), + 'description' => '', + 'public' => true, + 'internal' => false, + '_builtin' => false, + ); + + $args = array_merge( $defaults, $args ); + + $args['name'] = $this->name; + + /* + * Comment types are never hierarchical. The property exists only so the shared + * label helper can pick a slot, and the hierarchical slot is deliberately null, + * so honoring a provided value would resolve every default label to null. + */ + $args['hierarchical'] = false; + + foreach ( $args as $property_name => $property_value ) { + $this->$property_name = $property_value; + } + + $this->labels = get_comment_type_labels( $this ); + $this->label = $this->labels->name; + } + + /** + * Returns the default labels for comment types. + * + * @since 7.2.0 + * + * @return (string|null)[][] The default labels for comment types. + */ + public static function get_default_labels() { + if ( ! empty( self::$default_labels ) ) { + return self::$default_labels; + } + + self::$default_labels = array( + 'name' => array( _x( 'Comments', 'comment type general name' ), null ), + 'singular_name' => array( _x( 'Comment', 'comment type singular name' ), null ), + ); + + return self::$default_labels; + } + + /** + * Resets the cache for the default labels. + * + * @since 7.2.0 + */ + public static function reset_default_labels() { + self::$default_labels = array(); + } +} diff --git a/src/wp-includes/comment-template.php b/src/wp-includes/comment-template.php index 43bd68ff972a4..77702035862dc 100644 --- a/src/wp-includes/comment-template.php +++ b/src/wp-includes/comment-template.php @@ -1179,12 +1179,17 @@ function get_comment_type( $comment_id = 0 ) { * Displays the comment type of the current comment. * * @since 0.71 + * @since 7.2.0 The default output for a registered non-built-in comment type + * falls back to the type's singular name label. * * @param string|false $comment_text Optional. String to display for comment type. Default false. * @param string|false $trackback_text Optional. String to display for trackback type. Default false. * @param string|false $pingback_text Optional. String to display for pingback type. Default false. */ function comment_type( $comment_text = false, $trackback_text = false, $pingback_text = false ) { + // Whether the caller supplied custom text for the default comment label. + $comment_text_overridden = ( false !== $comment_text ); + if ( false === $comment_text ) { $comment_text = _x( 'Comment', 'noun' ); } @@ -1203,7 +1208,18 @@ function comment_type( $comment_text = false, $trackback_text = false, $pingback echo $pingback_text; break; default: - echo $comment_text; + /* + * For a registered, non-built-in comment type, fall back to its singular label + * when the caller did not supply custom text. Built-in types and explicit + * overrides keep their existing output. + */ + $comment_type_object = $comment_text_overridden ? null : get_comment_type_object( $type ); + + if ( $comment_type_object && ! $comment_type_object->_builtin && isset( $comment_type_object->labels->singular_name ) ) { + echo esc_html( $comment_type_object->labels->singular_name ); + } else { + echo $comment_text; + } } } diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 7557e9258c87f..10998c7c5d396 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -330,6 +330,389 @@ function get_comments( $args = '' ) { return $query->query( $args ); } +/** + * Creates the initial comment types when 'init' action is fired. + * + * See register_comment_type() for accepted arguments. + * + * @since 7.2.0 + */ +function create_initial_comment_types() { + WP_Comment_Type::reset_default_labels(); + + /* + * The 'comment', 'pingback', and 'trackback' labels deliberately reuse existing + * core translation strings, while 'note' introduces new strings with explicit + * contexts. Do not normalize one style to the other. + */ + register_comment_type( + 'comment', + array( + 'label' => __( 'Comments' ), + 'labels' => array( + 'singular_name' => _x( 'Comment', 'noun' ), + ), + 'public' => true, + '_builtin' => true, + ) + ); + + register_comment_type( + 'pingback', + array( + 'label' => __( 'Pingbacks' ), + 'labels' => array( + 'singular_name' => __( 'Pingback' ), + ), + 'public' => true, + '_builtin' => true, + ) + ); + + register_comment_type( + 'trackback', + array( + 'label' => __( 'Trackbacks' ), + 'labels' => array( + 'singular_name' => __( 'Trackback' ), + ), + 'public' => true, + '_builtin' => true, + ) + ); + + register_comment_type( + 'note', + array( + 'label' => _x( 'Notes', 'comment type general name' ), + 'labels' => array( + 'singular_name' => _x( 'Note', 'comment type singular name' ), + ), + 'public' => false, + 'internal' => true, + '_builtin' => true, + ) + ); +} + +/** + * Registers a comment type. + * + * Note: Comment type registrations should not be hooked before the {@see 'init'} action. + * Registering a comment type earlier can result in its labels being generated before + * the current locale's translations are loaded. + * + * Comment types are stored verbatim in the `comment_type` column of the comments table. + * Registration provides labels and metadata for a type; it does not constrain which values + * may be stored. Comment types that are stored but never registered keep behaving exactly + * as they did before this API existed. + * + * Each argument drives exactly one layer of behavior, so the flags stay independently + * meaningful as more of them are added: + * + * - `public` states display-surface intent: whether the type is meant to be seen by site + * visitors. It does not affect what queries return. + * - `internal` states query-surface intent: whether the type should be excluded from + * comment queries and counts by default. + * + * An argument never implies another argument. The one cascade planned for the future is + * `show_in_rest`, which will default from `public`. + * + * Registrations live in a per-process global. Like post types, they are not scoped to a + * site on multisite: a type registered by one site's plugins is visible after + * switch_to_blog() for the rest of the request. + * + * Cannot be used to re-register built-in comment types. The names WP_Comment_Query reads + * as query tokens ('all', 'comments', 'pings') cannot be registered either. + * + * @since 7.2.0 + * + * @global WP_Comment_Type[] $wp_comment_types List of comment types. + * + * @param string $comment_type Comment type key. Must not exceed 20 characters and may only + * contain lowercase alphanumeric characters, dashes, and underscores. + * See sanitize_key(). + * @param array|string $args { + * Optional. Array or string of arguments for registering a comment type. Default empty array. + * + * @type string $label Name of the comment type. Usually plural. + * Default is the value of $labels['name']. + * @type string[] $labels An array of labels for this comment type. If not set, the + * default comment labels are used. See get_comment_type_labels() + * for a full list of supported labels. + * @type string $description A short descriptive summary of what the comment type is. + * Default empty. + * @type bool $public Whether the comment type is intended for use publicly either via + * the admin interface or by front-end users. Core does not + * currently act on this argument. Default true. + * @type bool $internal Whether the comment type is for internal use only. Internal + * types are meant to be excluded from comment queries and counts + * by default. Core does not currently act on this argument. + * Default false. + * @type bool $_builtin For internal core use only. Marks the type as native to + * WordPress, which blocks it from being re-registered or + * unregistered. Default false. + * } + * @return WP_Comment_Type|WP_Error The registered comment type object on success, + * WP_Error object on failure. + */ +function register_comment_type( $comment_type, $args = array() ) { + global $wp_comment_types; + + if ( ! is_array( $wp_comment_types ) ) { + $wp_comment_types = array(); + } + + $args = wp_parse_args( $args ); + + // Sanitize comment type name. + $comment_type = sanitize_key( $comment_type ); + + if ( empty( $comment_type ) || strlen( $comment_type ) > 20 ) { + _doing_it_wrong( __FUNCTION__, __( 'Comment type names must be between 1 and 20 characters in length.' ), '7.2.0' ); + return new WP_Error( 'comment_type_length_invalid', __( 'Comment type names must be between 1 and 20 characters in length.' ) ); + } + + /* + * Re-registering a built-in comment type could strip flags that core relies on + * for rendering and query behavior, so it is not allowed. Core's own repeated + * registrations (on 'init' and 'change_locale') pass '_builtin' and are exempt. + */ + if ( isset( $wp_comment_types[ $comment_type ] ) + && $wp_comment_types[ $comment_type ]->_builtin + && empty( $args['_builtin'] ) + ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: %s: Comment type key. */ + __( 'The "%s" comment type is a built-in type and cannot be re-registered.' ), + $comment_type + ), + '7.2.0' + ); + return new WP_Error( 'comment_type_builtin', __( 'Built-in comment types cannot be re-registered.' ) ); + } + + /* + * WP_Comment_Query reads these names as query tokens rather than as literal + * comment_type values, so a type registered under one of them could never be + * queried for on its own. + */ + if ( in_array( $comment_type, array( 'all', 'comments', 'pings' ), true ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: %s: Comment type key. */ + __( 'The "%s" comment type name is reserved for use by WP_Comment_Query.' ), + $comment_type + ), + '7.2.0' + ); + return new WP_Error( 'comment_type_reserved', __( 'This comment type name is reserved.' ) ); + } + + $comment_type_object = new WP_Comment_Type( $comment_type, $args ); + + $wp_comment_types[ $comment_type ] = $comment_type_object; + + /** + * Fires after a comment type is registered. + * + * @since 7.2.0 + * + * @param string $comment_type Comment type key. + * @param WP_Comment_Type $comment_type_object Comment type object. + */ + do_action( 'registered_comment_type', $comment_type, $comment_type_object ); + + /** + * Fires after a specific comment type is registered. + * + * The dynamic portion of the filter name, `$comment_type`, refers to the comment type key. + * + * Possible hook names include: + * + * - `registered_comment_type_comment` + * - `registered_comment_type_pingback` + * + * @since 7.2.0 + * + * @param string $comment_type Comment type key. + * @param WP_Comment_Type $comment_type_object Comment type object. + */ + do_action( "registered_comment_type_{$comment_type}", $comment_type, $comment_type_object ); + + return $comment_type_object; +} + +/** + * Unregisters a comment type. + * + * Cannot be used to unregister built-in comment types. + * + * @since 7.2.0 + * + * @global WP_Comment_Type[] $wp_comment_types List of comment types. + * + * @param string $comment_type Comment type key. + * @return true|WP_Error True on success, WP_Error on failure or if the comment type doesn't exist. + */ +function unregister_comment_type( $comment_type ) { + global $wp_comment_types; + + if ( ! comment_type_exists( $comment_type ) ) { + return new WP_Error( 'invalid_comment_type', __( 'Invalid comment type.' ) ); + } + + $comment_type_object = get_comment_type_object( $comment_type ); + + // Do not allow unregistering built-in comment types. + if ( $comment_type_object->_builtin ) { + return new WP_Error( 'invalid_comment_type', __( 'Unregistering a built-in comment type is not allowed.' ) ); + } + + unset( $wp_comment_types[ $comment_type ] ); + + /** + * Fires after a comment type is unregistered. + * + * @since 7.2.0 + * + * @param string $comment_type Comment type key. + */ + do_action( 'unregistered_comment_type', $comment_type ); + + return true; +} + +/** + * Retrieves a comment type object by name. + * + * @since 7.2.0 + * + * @global WP_Comment_Type[] $wp_comment_types List of comment types. + * + * @param string $comment_type The name of a registered comment type. + * @return WP_Comment_Type|null WP_Comment_Type object if it exists, null otherwise. + */ +function get_comment_type_object( $comment_type ) { + global $wp_comment_types; + + if ( ! is_scalar( $comment_type ) || empty( $wp_comment_types[ $comment_type ] ) ) { + return null; + } + + return $wp_comment_types[ $comment_type ]; +} + +/** + * Retrieves a list of registered comment type names or objects. + * + * @since 7.2.0 + * + * @global WP_Comment_Type[] $wp_comment_types List of comment types. + * + * @param array $args Optional. An array of key => value arguments to match against + * the comment type objects. Default empty array. + * @param string $output Optional. The type of output to return. Either comment type 'names' + * or 'objects'. Default 'names'. + * @param string $operator Optional. The logical operation to perform. 'or' means only one + * element from the array needs to match; 'and' means all elements + * must match; 'not' means no elements may match. Default 'and'. + * @return string[]|WP_Comment_Type[] An array of comment type names or objects. + */ +function get_comment_types( $args = array(), $output = 'names', $operator = 'and' ) { + global $wp_comment_types; + + $field = ( 'names' === $output ) ? 'name' : false; + + return wp_filter_object_list( $wp_comment_types, $args, $operator, $field ); +} + +/** + * Determines whether a comment type is registered. + * + * @since 7.2.0 + * + * @param string $comment_type Comment type name. + * @return bool Whether the comment type is registered. + */ +function comment_type_exists( $comment_type ) { + return (bool) get_comment_type_object( $comment_type ); +} + +/** + * Builds an object with all comment type labels out of a comment type object. + * + * @since 7.2.0 + * + * @param WP_Comment_Type $comment_type_object Comment type object. + * @return object { + * Comment type labels object. + * + * @type string $name General name for the comment type, usually plural. The same as and + * overridden by `$comment_type_object->label`. Default 'Comments'. + * @type string $singular_name Name for one object of this comment type. Default 'Comment'. + * @type string $menu_name Label for the menu name. Default is the same as `name`. + * } + */ +function get_comment_type_labels( $comment_type_object ) { + $nohier_vs_hier_defaults = WP_Comment_Type::get_default_labels(); + + $nohier_vs_hier_defaults['menu_name'] = $nohier_vs_hier_defaults['name']; + + $provided_labels = (array) $comment_type_object->labels; + + /* + * _get_custom_object_labels() writes every label it derives back onto the object + * it is given, including the post-type-only labels removed below. Hand it a copy + * so calling this function on a registered comment type leaves the registered + * object untouched. + */ + $labels = _get_custom_object_labels( clone $comment_type_object, $nohier_vs_hier_defaults ); + + /* + * _get_custom_object_labels() derives labels that only apply to post types. + * Remove them unless they were explicitly provided at registration. + */ + foreach ( array( 'name_admin_bar', 'all_items', 'archives' ) as $post_type_only_label ) { + if ( ! array_key_exists( $post_type_only_label, $provided_labels ) ) { + unset( $labels->$post_type_only_label ); + } + } + + $comment_type = $comment_type_object->name; + + $default_labels = clone $labels; + + /** + * Filters the labels of a specific comment type. + * + * The dynamic portion of the hook name, `$comment_type`, refers to the comment type slug. + * + * Possible hook names include: + * + * - `comment_type_labels_comment` + * - `comment_type_labels_pingback` + * + * Labels are stored unescaped, mirroring the post type and taxonomy label + * contract; callers must escape them on output (for example with esc_html()). + * + * @since 7.2.0 + * + * @see get_comment_type_labels() for the full list of comment type labels. + * + * @param object $labels Object with labels for the comment type as member variables. + */ + $labels = apply_filters( "comment_type_labels_{$comment_type}", $labels ); + + // Ensure that the filtered labels contain all required default values. + $labels = (object) array_merge( (array) $default_labels, (array) $labels ); + + return $labels; +} + /** * Retrieves all of the WordPress supported comment statuses. * diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 12ca0045b98b4..39e45451b8665 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -528,6 +528,10 @@ add_action( 'split_shared_term', '_wp_check_split_nav_menu_terms', 10, 4 ); add_action( 'wp_split_shared_term_batch', '_wp_batch_split_terms' ); +// Comment types. +add_action( 'init', 'create_initial_comment_types', 0 ); // Highest priority. +add_action( 'change_locale', 'create_initial_comment_types' ); + // Comment type updates. add_action( 'admin_init', '_wp_check_for_scheduled_update_comment_type' ); add_action( 'wp_update_comment_type_batch', '_wp_batch_update_comment_type' ); diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 2db73e9a20476..fa01a58d0df49 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -2221,8 +2221,8 @@ function get_post_type_labels( $post_type_object ) { } /** - * Builds an object with custom-something object (post type, taxonomy) labels - * out of a custom-something object + * Builds an object with custom-something object (post type, taxonomy, comment type) + * labels out of a custom-something object * * @since 3.0.0 * @access private diff --git a/src/wp-settings.php b/src/wp-settings.php index 29b7588b96b4f..884243e45ba0a 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -233,6 +233,7 @@ require ABSPATH . WPINC . '/comment.php'; require ABSPATH . WPINC . '/class-wp-comment.php'; require ABSPATH . WPINC . '/class-wp-comment-query.php'; +require ABSPATH . WPINC . '/class-wp-comment-type.php'; require ABSPATH . WPINC . '/class-walker-comment.php'; require ABSPATH . WPINC . '/comment-template.php'; require ABSPATH . WPINC . '/rewrite.php'; @@ -560,10 +561,11 @@ // Create common globals. require ABSPATH . WPINC . '/vars.php'; -// Make taxonomies and posts available to plugins and themes. +// Make taxonomies, posts, and comment types available to plugins and themes. // @plugin authors: warning: these get registered again on the init hook. create_initial_taxonomies(); create_initial_post_types(); +create_initial_comment_types(); wp_start_scraping_edited_file_errors(); diff --git a/tests/phpunit/includes/abstract-testcase.php b/tests/phpunit/includes/abstract-testcase.php index c87e5e7fd077c..9e7b1351412a6 100644 --- a/tests/phpunit/includes/abstract-testcase.php +++ b/tests/phpunit/includes/abstract-testcase.php @@ -123,15 +123,16 @@ public function set_up() { $this->clean_up_global_scope(); /* - * When running core tests, ensure that post types and taxonomies - * are reset for each test. We skip this step for non-core tests, - * given the large number of plugins that register post types and - * taxonomies at 'init'. + * When running core tests, ensure that post types, taxonomies, + * and comment types are reset for each test. We skip this step + * for non-core tests, given the large number of plugins that + * register post types and taxonomies at 'init'. */ if ( defined( 'WP_RUN_CORE_TESTS' ) && WP_RUN_CORE_TESTS ) { $this->reset_post_types(); $this->reset_taxonomies(); $this->reset_post_statuses(); + $this->reset_comment_types(); $this->reset__SERVER(); if ( $wp_rewrite->permalink_structure ) { @@ -350,6 +351,24 @@ protected function reset_taxonomies() { create_initial_taxonomies(); } + /** + * Unregisters existing comment types and registers defaults. + * + * Run before each test in order to clean up the global scope, in case + * a test forgets to unregister a comment type on its own, or fails before + * it has a chance to do so. + * + * The registry is emptied outright rather than unregistered type by type, since a + * test can register a custom type with '_builtin' set and that type would otherwise + * survive into the next test. Registering a comment type creates no hooks, rewrite + * rules, or meta boxes, so dropping the registry is complete cleanup. + */ + protected function reset_comment_types() { + $GLOBALS['wp_comment_types'] = array(); + + create_initial_comment_types(); + } + /** * Unregisters non-built-in post statuses. */ diff --git a/tests/phpunit/tests/comment/commentType.php b/tests/phpunit/tests/comment/commentType.php new file mode 100644 index 0000000000000..7d52cc9a20bdb --- /dev/null +++ b/tests/phpunit/tests/comment/commentType.php @@ -0,0 +1,145 @@ +post->create(); + } + + /** + * Returns the output of comment_type() for a comment of the given type. + * + * @param string $type Comment type stored on the comment. + * @param mixed ...$args Optional arguments passed through to comment_type(). + * @return string Captured output. + */ + private function get_comment_type_output( $type, ...$args ) { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => $type, + ) + ); + + $GLOBALS['comment'] = get_comment( $comment_id ); + + ob_start(); + comment_type( ...$args ); + $output = ob_get_clean(); + + unset( $GLOBALS['comment'] ); + + return $output; + } + + /** + * @ticket 35214 + */ + public function test_built_in_types_output_is_unchanged() { + $this->assertSame( 'Comment', $this->get_comment_type_output( 'comment' ) ); + $this->assertSame( 'Trackback', $this->get_comment_type_output( 'trackback' ) ); + $this->assertSame( 'Pingback', $this->get_comment_type_output( 'pingback' ) ); + } + + /** + * @ticket 35214 + */ + public function test_custom_text_overrides_are_respected() { + $this->assertSame( 'C', $this->get_comment_type_output( 'comment', 'C', 'T', 'P' ) ); + $this->assertSame( 'T', $this->get_comment_type_output( 'trackback', 'C', 'T', 'P' ) ); + $this->assertSame( 'P', $this->get_comment_type_output( 'pingback', 'C', 'T', 'P' ) ); + } + + /** + * @ticket 35214 + */ + public function test_registered_custom_type_outputs_its_label() { + register_comment_type( + 'foo', + array( + 'labels' => array( + 'singular_name' => 'Foo', + ), + ) + ); + + $this->assertSame( 'Foo', $this->get_comment_type_output( 'foo' ) ); + } + + /** + * @ticket 35214 + */ + public function test_unregistered_custom_type_falls_back_to_default_label() { + $this->assertSame( _x( 'Comment', 'noun' ), $this->get_comment_type_output( 'bar' ) ); + } + + /** + * A comment stored with the legacy empty string type is treated as 'comment'. + * + * @ticket 35214 + */ + public function test_legacy_empty_type_outputs_comment() { + $this->assertSame( 'Comment', $this->get_comment_type_output( '' ) ); + } + + /** + * The label fallback must not apply to built-in types: 'note' has 'Note' labels + * but comment_type() output stays 'Comment'. + * + * @ticket 35214 + */ + public function test_built_in_note_type_outputs_default_comment_text() { + $this->assertSame( 'Comment', $this->get_comment_type_output( 'note' ) ); + } + + /** + * @ticket 35214 + */ + public function test_custom_text_override_wins_over_registered_label() { + register_comment_type( + 'foo', + array( + 'labels' => array( + 'singular_name' => 'Foo', + ), + ) + ); + + $this->assertSame( 'Custom', $this->get_comment_type_output( 'foo', 'Custom' ) ); + } + + /** + * The registered label is escaped on output to guard against HTML/script injection. + * + * @ticket 35214 + */ + public function test_registered_label_is_escaped_on_output() { + register_comment_type( + 'foo', + array( + 'labels' => array( + 'singular_name' => 'Foo', + ), + ) + ); + + $this->assertSame( + esc_html( 'Foo' ), + $this->get_comment_type_output( 'foo' ) + ); + } +} diff --git a/tests/phpunit/tests/comment/types.php b/tests/phpunit/tests/comment/types.php new file mode 100644 index 0000000000000..58fdb791127b0 --- /dev/null +++ b/tests/phpunit/tests/comment/types.php @@ -0,0 +1,718 @@ +assertNull( get_comment_type_object( 'foo' ) ); + + register_comment_type( 'foo' ); + + $cobj = get_comment_type_object( 'foo' ); + $this->assertInstanceOf( 'WP_Comment_Type', $cobj ); + $this->assertSame( 'foo', $cobj->name ); + + // Test some defaults. + $this->assertTrue( $cobj->public ); + $this->assertFalse( $cobj->internal ); + $this->assertFalse( $cobj->_builtin ); + } + + /** + * @ticket 35214 + * + * @covers ::register_comment_type + * @covers ::get_comment_type_labels + */ + public function test_register_comment_type_without_labels_uses_default_labels() { + register_comment_type( 'foo' ); + + $cobj = get_comment_type_object( 'foo' ); + + $this->assertSame( 'Comments', $cobj->label ); + $this->assertSame( 'Comments', $cobj->labels->name ); + $this->assertSame( 'Comment', $cobj->labels->singular_name ); + } + + /** + * @ticket 35214 + * + * @covers ::register_comment_type + */ + public function test_register_comment_type_return_value() { + $this->assertInstanceOf( 'WP_Comment_Type', register_comment_type( 'foo' ) ); + } + + /** + * @ticket 35214 + * + * @covers ::register_comment_type + * + * @expectedIncorrectUsage register_comment_type + */ + public function test_register_comment_type_with_too_long_name() { + $this->assertInstanceOf( 'WP_Error', register_comment_type( 'comment_type_with_a_too_long_name' ) ); + } + + /** + * @ticket 35214 + * + * @covers ::register_comment_type + * + * @expectedIncorrectUsage register_comment_type + */ + public function test_register_comment_type_with_empty_name() { + $this->assertInstanceOf( 'WP_Error', register_comment_type( '' ) ); + } + + /** + * @ticket 35214 + * + * @covers ::create_initial_comment_types + */ + public function test_built_in_comment_types_are_registered() { + $this->assertTrue( comment_type_exists( 'comment' ) ); + $this->assertTrue( comment_type_exists( 'pingback' ) ); + $this->assertTrue( comment_type_exists( 'trackback' ) ); + $this->assertTrue( comment_type_exists( 'note' ) ); + } + + /** + * @ticket 35214 + * + * @covers ::create_initial_comment_types + */ + public function test_built_in_note_type_is_internal_and_non_public() { + $note = get_comment_type_object( 'note' ); + + $this->assertTrue( $note->internal ); + $this->assertFalse( $note->public ); + } + + /** + * @ticket 35214 + * + * @covers ::comment_type_exists + */ + public function test_comment_type_exists() { + $this->assertFalse( comment_type_exists( 'foo' ) ); + + register_comment_type( 'foo' ); + + $this->assertTrue( comment_type_exists( 'foo' ) ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_types + */ + public function test_get_comment_types_names() { + register_comment_type( 'foo' ); + + $types = get_comment_types(); + + $this->assertContains( 'comment', $types ); + $this->assertContains( 'foo', $types ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_types + */ + public function test_get_comment_types_objects() { + register_comment_type( 'foo' ); + + $types = get_comment_types( array(), 'objects' ); + + $this->assertInstanceOf( 'WP_Comment_Type', $types['foo'] ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_types + */ + public function test_get_comment_types_filtered_by_property() { + register_comment_type( 'foo', array( 'public' => false ) ); + + $public = get_comment_types( array( 'public' => true ) ); + + $this->assertContains( 'comment', $public ); + $this->assertNotContains( 'foo', $public ); + $this->assertNotContains( 'note', $public ); + } + + /** + * @ticket 35214 + * + * @covers ::unregister_comment_type + */ + public function test_unregister_comment_type() { + register_comment_type( 'foo' ); + + $this->assertTrue( unregister_comment_type( 'foo' ) ); + $this->assertNull( get_comment_type_object( 'foo' ) ); + } + + /** + * @ticket 35214 + * + * @covers ::unregister_comment_type + */ + public function test_unregister_comment_type_unknown_returns_error() { + $this->assertWPError( unregister_comment_type( 'does_not_exist' ) ); + } + + /** + * @ticket 35214 + * + * @covers ::unregister_comment_type + */ + public function test_unregister_comment_type_twice_returns_error() { + register_comment_type( 'foo' ); + + $this->assertTrue( unregister_comment_type( 'foo' ) ); + $this->assertWPError( unregister_comment_type( 'foo' ) ); + } + + /** + * @ticket 35214 + * + * @covers ::unregister_comment_type + * + * @dataProvider data_built_in_comment_types + */ + public function test_unregister_built_in_comment_type_is_not_allowed( $comment_type ) { + $this->assertWPError( unregister_comment_type( $comment_type ) ); + $this->assertTrue( comment_type_exists( $comment_type ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_built_in_comment_types() { + return array( + array( 'comment' ), + array( 'pingback' ), + array( 'trackback' ), + array( 'note' ), + ); + } + + /** + * @ticket 35214 + * + * @covers ::register_comment_type + * + * @expectedIncorrectUsage register_comment_type + * + * @dataProvider data_built_in_comment_types + */ + public function test_register_built_in_comment_type_is_rejected( $comment_type ) { + $original_label = get_comment_type_object( $comment_type )->label; + + $result = register_comment_type( $comment_type, array( 'label' => 'Hijacked' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'comment_type_builtin', $result->get_error_code() ); + $this->assertSame( $original_label, get_comment_type_object( $comment_type )->label ); + } + + /** + * @ticket 35214 + * + * @covers ::register_comment_type + */ + public function test_register_comment_type_twice_overwrites_previous_registration() { + register_comment_type( 'foo', array( 'label' => 'First' ) ); + register_comment_type( 'foo', array( 'label' => 'Second' ) ); + + $this->assertSame( 'Second', get_comment_type_object( 'foo' )->label ); + } + + /** + * @ticket 35214 + * + * @covers ::register_comment_type + * @covers ::unregister_comment_type + */ + public function test_register_after_unregister_succeeds() { + register_comment_type( 'foo' ); + unregister_comment_type( 'foo' ); + + $this->assertInstanceOf( 'WP_Comment_Type', register_comment_type( 'foo' ) ); + $this->assertTrue( comment_type_exists( 'foo' ) ); + } + + /** + * @ticket 35214 + * + * @covers ::register_comment_type + */ + public function test_registered_comment_type_actions_fire() { + $action = new MockAction(); + $action_for_foo = new MockAction(); + + add_action( 'registered_comment_type', array( $action, 'action' ) ); + add_action( 'registered_comment_type_foo', array( $action_for_foo, 'action' ) ); + + register_comment_type( 'foo' ); + + $this->assertSame( 1, $action->get_call_count() ); + $this->assertSame( 1, $action_for_foo->get_call_count() ); + } + + /** + * @ticket 35214 + * + * @covers ::unregister_comment_type + */ + public function test_unregistered_comment_type_action_fires() { + register_comment_type( 'foo' ); + + $action = new MockAction(); + add_action( 'unregistered_comment_type', array( $action, 'action' ) ); + + unregister_comment_type( 'foo' ); + + $this->assertSame( 1, $action->get_call_count() ); + } + + /** + * @ticket 35214 + * + * @covers ::register_comment_type + */ + public function test_register_comment_type_with_20_character_name_succeeds() { + $comment_type = str_repeat( 'a', 20 ); + + $this->assertInstanceOf( 'WP_Comment_Type', register_comment_type( $comment_type ) ); + $this->assertTrue( comment_type_exists( $comment_type ) ); + } + + /** + * @ticket 35214 + * + * @covers ::register_comment_type + */ + public function test_register_comment_type_name_is_sanitized() { + $comment_type_object = register_comment_type( 'Foo Bar!' ); + + $this->assertSame( 'foobar', $comment_type_object->name ); + $this->assertFalse( comment_type_exists( 'Foo Bar!' ) ); + $this->assertTrue( comment_type_exists( 'foobar' ) ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_types + */ + public function test_get_comment_types_with_or_operator() { + register_comment_type( 'foo', array( 'public' => false ) ); + + $types = get_comment_types( + array( + 'public' => true, + 'internal' => true, + ), + 'names', + 'or' + ); + + // 'comment' matches on public, 'note' matches on internal. + $this->assertContains( 'comment', $types ); + $this->assertContains( 'note', $types ); + $this->assertNotContains( 'foo', $types ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_types + */ + public function test_get_comment_types_with_not_operator() { + register_comment_type( 'foo', array( 'internal' => true ) ); + + $types = get_comment_types( array( 'internal' => true ), 'names', 'not' ); + + $this->assertContains( 'comment', $types ); + $this->assertNotContains( 'note', $types ); + $this->assertNotContains( 'foo', $types ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_types + */ + public function test_get_comment_types_names_output_is_keyed_by_type_name() { + register_comment_type( 'foo' ); + + $types = get_comment_types(); + + $this->assertSame( 'foo', $types['foo'] ); + $this->assertSame( 'comment', $types['comment'] ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_type_object + */ + public function test_get_comment_type_object_with_non_scalar_returns_null() { + $this->assertNull( get_comment_type_object( array() ) ); + } + + /** + * @ticket 35214 + * + * @covers ::create_initial_comment_types + */ + public function test_create_initial_comment_types_is_idempotent() { + create_initial_comment_types(); + create_initial_comment_types(); + + $this->assertCount( 4, get_comment_types( array( '_builtin' => true ) ) ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_type_labels + */ + public function test_labels_are_built_from_args() { + register_comment_type( + 'foo', + array( + 'label' => 'Foos', + 'labels' => array( + 'singular_name' => 'Foo', + ), + ) + ); + + $cobj = get_comment_type_object( 'foo' ); + + $this->assertSame( 'Foos', $cobj->label ); + $this->assertSame( 'Foos', $cobj->labels->name ); + $this->assertSame( 'Foo', $cobj->labels->singular_name ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_type_labels + */ + public function test_comment_type_labels_filter() { + add_filter( + 'comment_type_labels_foo', + static function ( $labels ) { + $labels->singular_name = 'Filtered Foo'; + return $labels; + } + ); + + register_comment_type( 'foo' ); + + $this->assertSame( 'Filtered Foo', get_comment_type_object( 'foo' )->labels->singular_name ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_type_labels + */ + public function test_label_only_registration_populates_label_fallback_chain() { + register_comment_type( 'foo', array( 'label' => 'Foos' ) ); + + $labels = get_comment_type_object( 'foo' )->labels; + + $this->assertSame( 'Foos', $labels->name ); + $this->assertSame( 'Foos', $labels->singular_name ); + $this->assertSame( 'Foos', $labels->menu_name ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_type_labels + */ + public function test_labels_do_not_include_post_type_only_labels() { + register_comment_type( 'foo', array( 'label' => 'Foos' ) ); + + $labels = get_comment_type_object( 'foo' )->labels; + + $this->assertObjectNotHasProperty( 'name_admin_bar', $labels ); + $this->assertObjectNotHasProperty( 'all_items', $labels ); + $this->assertObjectNotHasProperty( 'archives', $labels ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_type_labels + */ + public function test_get_comment_type_labels_does_not_modify_a_registered_type() { + register_comment_type( 'foo', array( 'label' => 'Foos' ) ); + + $comment_type_object = get_comment_type_object( 'foo' ); + $registered_labels = get_object_vars( $comment_type_object->labels ); + + $first = get_comment_type_labels( $comment_type_object ); + $second = get_comment_type_labels( $comment_type_object ); + + $this->assertSame( $registered_labels, get_object_vars( $comment_type_object->labels ), 'The registered labels should not change.' ); + $this->assertEquals( $first, $second, 'Repeated calls should return the same labels.' ); + $this->assertObjectNotHasProperty( 'archives', $second, 'Post-type-only labels should not appear on repeated calls.' ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_type_labels + */ + public function test_labels_do_not_spawn_post_type_only_labels_from_menu_name() { + register_comment_type( + 'foo', + array( + 'label' => 'Foos', + 'labels' => array( + 'menu_name' => 'Foo Menu', + ), + ) + ); + + $labels = get_comment_type_object( 'foo' )->labels; + + $this->assertSame( 'Foo Menu', $labels->menu_name ); + $this->assertObjectNotHasProperty( 'all_items', $labels ); + $this->assertObjectNotHasProperty( 'archives', $labels ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_type_labels + */ + public function test_comment_type_labels_filter_missing_name_is_backfilled() { + add_filter( + 'comment_type_labels_foo', + static function ( $labels ) { + unset( $labels->name ); + return $labels; + } + ); + + register_comment_type( 'foo', array( 'label' => 'Foos' ) ); + + $this->assertSame( 'Foos', get_comment_type_object( 'foo' )->labels->name ); + } + + /** + * @ticket 35214 + * + * @covers ::register_comment_type + */ + public function test_registered_comment_type_action_receives_type_and_object() { + $action = new MockAction(); + + add_action( 'registered_comment_type', array( $action, 'action' ), 10, 2 ); + + register_comment_type( 'foo' ); + + $args = $action->get_args(); + + $this->assertSame( 'foo', $args[0][0] ); + $this->assertInstanceOf( 'WP_Comment_Type', $args[0][1] ); + $this->assertSame( 'foo', $args[0][1]->name ); + } + + /** + * Comment types are never hierarchical. The default labels reserve the hierarchical + * slot as null, so honoring a provided value would resolve every label to null. + * + * @ticket 35214 + * + * @covers WP_Comment_Type::set_props + */ + public function test_register_comment_type_ignores_hierarchical_argument() { + register_comment_type( 'foo', array( 'hierarchical' => true ) ); + + $cobj = get_comment_type_object( 'foo' ); + + $this->assertFalse( $cobj->hierarchical, 'A comment type should never be hierarchical.' ); + $this->assertSame( 'Comments', $cobj->label, 'The default label should survive the argument.' ); + $this->assertSame( 'Comments', $cobj->labels->name ); + $this->assertSame( 'Comment', $cobj->labels->singular_name ); + } + + /** + * The names WP_Comment_Query reads as query tokens cannot be registered, since a type + * stored under one of them could never be queried for on its own. + * + * @ticket 35214 + * + * @covers ::register_comment_type + * + * @dataProvider data_reserved_comment_type_names + * + * @expectedIncorrectUsage register_comment_type + * + * @param string $comment_type Reserved comment type name. + */ + public function test_register_reserved_comment_type_is_rejected( string $comment_type ) { + $result = register_comment_type( $comment_type ); + + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertSame( 'comment_type_reserved', $result->get_error_code() ); + $this->assertNull( get_comment_type_object( $comment_type ) ); + } + + /** + * Data provider for test_register_reserved_comment_type_is_rejected(). + * + * @return array + */ + public function data_reserved_comment_type_names(): array { + return array( + 'all type token' => array( 'all' ), + 'comments alias' => array( 'comments' ), + 'pings bucket key' => array( 'pings' ), + ); + } + + /** + * The built-in guard reads the raw arguments, so passing '_builtin' bypasses it. This + * matches register_post_type(), where '_builtin' is an accepted internal-use argument + * with no guard at all. Pinned so the follow-ups that give '_builtin' more meaning + * cannot change it by accident. + * + * @ticket 35214 + * + * @covers ::register_comment_type + */ + public function test_register_comment_type_builtin_argument_bypasses_the_built_in_guard() { + $result = register_comment_type( + 'pingback', + array( + '_builtin' => true, + 'label' => 'Hijacked', + ) + ); + + $this->assertInstanceOf( 'WP_Comment_Type', $result, 'Passing _builtin bypasses the guard.' ); + $this->assertSame( 'Hijacked', get_comment_type_object( 'pingback' )->label ); + } + + /** + * Passing '_builtin' on a custom type makes it behave like a built-in for everyone + * else: it can no longer be re-registered or unregistered. + * + * @ticket 35214 + * + * @covers ::register_comment_type + * @covers ::unregister_comment_type + * + * @expectedIncorrectUsage register_comment_type + */ + public function test_register_comment_type_builtin_argument_locks_a_custom_type() { + register_comment_type( 'foo', array( '_builtin' => true ) ); + + $this->assertInstanceOf( 'WP_Error', register_comment_type( 'foo' ) ); + $this->assertInstanceOf( 'WP_Error', unregister_comment_type( 'foo' ) ); + } + + /** + * Built-in labels are rebuilt on a locale change rather than served from the static + * default-labels cache. + * + * @ticket 35214 + * + * @covers ::create_initial_comment_types + */ + public function test_built_in_labels_are_rebuilt_on_locale_change() { + $original = get_comment_type_object( 'comment' )->label; + + add_filter( + 'gettext', + static function ( $translation, $text ) { + return 'Comments' === $text ? 'Kommentare' : $translation; + }, + 10, + 2 + ); + + do_action( 'change_locale', 'de_DE' ); + + $this->assertSame( + 'Kommentare', + get_comment_type_object( 'comment' )->label, + 'A locale change should rebuild the built-in labels.' + ); + $this->assertNotSame( $original, get_comment_type_object( 'comment' )->label ); + } + + /** + * Before wp-settings.php registers the built-ins, the registry global does not exist. + * Both accessors have to cope with that rather than warn. + * + * @ticket 35214 + * + * @covers ::get_comment_types + * @covers ::get_comment_type_object + */ + public function test_accessors_handle_an_unset_registry() { + $registry = $GLOBALS['wp_comment_types']; + unset( $GLOBALS['wp_comment_types'] ); + + try { + $this->assertSame( array(), get_comment_types() ); + $this->assertNull( get_comment_type_object( 'comment' ) ); + } finally { + $GLOBALS['wp_comment_types'] = $registry; + } + } + + /** + * The registry is a per-process global, so it is not scoped to a site. This matches + * post types, and is worth pinning because comment counts and query exclusions are + * per-site data. + * + * @ticket 35214 + * + * @group ms-required + * + * @covers ::register_comment_type + */ + public function test_registry_is_not_scoped_to_a_site() { + register_comment_type( 'foo' ); + + $blog_id = self::factory()->blog->create(); + + switch_to_blog( $blog_id ); + + $registered_after_switch = comment_type_exists( 'foo' ); + + restore_current_blog(); + + $this->assertTrue( + $registered_after_switch, + 'A registered comment type should still be registered after switch_to_blog().' + ); + } +} diff --git a/tests/phpunit/tests/comment/wpCommentType.php b/tests/phpunit/tests/comment/wpCommentType.php new file mode 100644 index 0000000000000..dc316aac4a735 --- /dev/null +++ b/tests/phpunit/tests/comment/wpCommentType.php @@ -0,0 +1,124 @@ +assertSame( 'foo', $comment_type->name ); + $this->assertTrue( $comment_type->public ); + $this->assertFalse( $comment_type->internal ); + $this->assertFalse( $comment_type->_builtin ); + $this->assertFalse( $comment_type->hierarchical ); + } + + /** + * @ticket 35214 + * + * @covers ::set_props + */ + public function test_set_props_overrides_defaults() { + $comment_type = new WP_Comment_Type( + 'foo', + array( + 'public' => false, + 'internal' => true, + 'description' => 'A test comment type.', + ) + ); + + $this->assertFalse( $comment_type->public ); + $this->assertTrue( $comment_type->internal ); + $this->assertSame( 'A test comment type.', $comment_type->description ); + } + + /** + * @ticket 35214 + * + * @covers ::set_props + */ + public function test_register_comment_type_args_filter() { + $filter = static function ( $args ) { + $args['public'] = false; + return $args; + }; + + add_filter( 'register_comment_type_args', $filter ); + $comment_type = new WP_Comment_Type( 'foo' ); + remove_filter( 'register_comment_type_args', $filter ); + + $this->assertFalse( $comment_type->public ); + } + + /** + * @ticket 35214 + * + * @covers ::set_props + */ + public function test_register_specific_comment_type_args_filter() { + $filter = static function ( $args ) { + $args['description'] = 'Filtered description.'; + return $args; + }; + + add_filter( 'register_foo_comment_type_args', $filter ); + $comment_type = new WP_Comment_Type( 'foo' ); + $other_type = new WP_Comment_Type( 'bar' ); + remove_filter( 'register_foo_comment_type_args', $filter ); + + $this->assertSame( 'Filtered description.', $comment_type->description ); + $this->assertSame( '', $other_type->description ); + } + + /** + * @ticket 35214 + * + * @covers ::get_default_labels + * @covers ::reset_default_labels + */ + public function test_get_default_labels_returns_expected_defaults() { + WP_Comment_Type::reset_default_labels(); + + $labels = WP_Comment_Type::get_default_labels(); + + $this->assertSame( 'Comments', $labels['name'][0] ); + $this->assertSame( 'Comment', $labels['singular_name'][0] ); + } + + /** + * @ticket 35214 + * + * @covers ::get_default_labels + * @covers ::reset_default_labels + */ + public function test_reset_default_labels_clears_cache() { + // Poison the static cache so a stale value is observable. + $property = new ReflectionProperty( WP_Comment_Type::class, 'default_labels' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $property->setValue( null, array( 'name' => array( 'Poisoned', null ) ) ); + + $labels = WP_Comment_Type::get_default_labels(); + $this->assertSame( 'Poisoned', $labels['name'][0], 'The poisoned cache should be served as-is.' ); + + WP_Comment_Type::reset_default_labels(); + + // A fresh call rebuilds the defaults from translation functions. + $labels = WP_Comment_Type::get_default_labels(); + $this->assertSame( 'Comments', $labels['name'][0], 'Resetting should rebuild the default labels.' ); + } +}