diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index eb0d8c1926190..5fa058363af9b 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -588,8 +588,12 @@ function create_initial_post_types() { ), 'map_meta_cap' => true, 'query_var' => false, - 'show_in_rest' => false, 'rewrite' => false, + 'show_in_rest' => true, + 'rest_base' => 'font-families', + 'rest_controller_class' => 'WP_REST_Font_Families_Controller', + // Disable autosave endpoints for font families. + 'autosave_rest_controller_class' => 'stdClass', ) ); @@ -617,8 +621,12 @@ function create_initial_post_types() { ), 'map_meta_cap' => true, 'query_var' => false, - 'show_in_rest' => false, 'rewrite' => false, + 'show_in_rest' => true, + 'rest_base' => 'font-families/(?P[\d]+)/font-faces', + 'rest_controller_class' => 'WP_REST_Font_Faces_Controller', + // Disable autosave endpoints for font faces. + 'autosave_rest_controller_class' => 'stdClass', ) ); diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 2631a6663fe92..b44b205afb6ef 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -391,6 +391,10 @@ function create_initial_rest_routes() { // Navigation Fallback. $controller = new WP_REST_Navigation_Fallback_Controller(); $controller->register_routes(); + + // Font Collections. + $font_collections_controller = new WP_REST_Font_Collections_Controller(); + $font_collections_controller->register_routes(); } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-collections-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-collections-controller.php new file mode 100644 index 0000000000000..89326ab580636 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-collections-controller.php @@ -0,0 +1,329 @@ +rest_base = 'font-collections'; + $this->namespace = 'wp/v2'; + } + + /** + * Registers the routes for the objects of the controller. + * + * @since 6.5.0 + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\/\w-]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Gets the font collections available. + * + * @since 6.5.0 + * + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $collections_all = WP_Font_Library::get_instance()->get_font_collections(); + + $page = $request['page']; + $per_page = $request['per_page']; + $total_items = count( $collections_all ); + $max_pages = ceil( $total_items / $per_page ); + + if ( $page > $max_pages && $total_items > 0 ) { + return new WP_Error( + 'rest_post_invalid_page_number', + __( 'The page number requested is larger than the number of pages available.' ), + array( 'status' => 400 ) + ); + } + + $collections_page = array_slice( $collections_all, ( $page - 1 ) * $per_page, $per_page ); + + $items = array(); + foreach ( $collections_page as $collection ) { + $item = $this->prepare_item_for_response( $collection, $request ); + + // If there's an error loading a collection, skip it and continue loading valid collections. + if ( is_wp_error( $item ) ) { + continue; + } + $item = $this->prepare_response_for_collection( $item ); + $items[] = $item; + } + + $response = rest_ensure_response( $items ); + + $response->header( 'X-WP-Total', (int) $total_items ); + $response->header( 'X-WP-TotalPages', (int) $max_pages ); + + $request_params = $request->get_query_params(); + $collection_url = rest_url( $this->namespace . '/' . $this->rest_base ); + $base = add_query_arg( urlencode_deep( $request_params ), $collection_url ); + + if ( $page > 1 ) { + $prev_page = $page - 1; + + if ( $prev_page > $max_pages ) { + $prev_page = $max_pages; + } + + $prev_link = add_query_arg( 'page', $prev_page, $base ); + $response->link_header( 'prev', $prev_link ); + } + if ( $max_pages > $page ) { + $next_page = $page + 1; + $next_link = add_query_arg( 'page', $next_page, $base ); + + $response->link_header( 'next', $next_link ); + } + + return $response; + } + + /** + * Gets a font collection. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_item( $request ) { + $slug = $request->get_param( 'slug' ); + $collection = WP_Font_Library::get_instance()->get_font_collection( $slug ); + + // If the collection doesn't exist returns a 404. + if ( is_wp_error( $collection ) ) { + $collection->add_data( array( 'status' => 404 ) ); + return $collection; + } + + return $this->prepare_item_for_response( $collection, $request ); + } + + /** + * Prepare a single collection output for response. + * + * @since 6.5.0 + * + * @param WP_Font_Collection $collection Collection object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $collection, $request ) { + $fields = $this->get_fields_for_response( $request ); + $item = array(); + + if ( rest_is_field_included( 'slug', $fields ) ) { + $item['slug'] = $collection->slug; + } + + // If any data fields are requested, get the collection data. + $data_fields = array( 'name', 'description', 'font_families', 'categories' ); + if ( ! empty( array_intersect( $fields, $data_fields ) ) ) { + $collection_data = $collection->get_data(); + if ( is_wp_error( $collection_data ) ) { + $collection_data->add_data( array( 'status' => 500 ) ); + return $collection_data; + } + + foreach ( $data_fields as $field ) { + if ( rest_is_field_included( $field, $fields ) ) { + $item[ $field ] = $collection_data[ $field ]; + } + } + } + + $response = rest_ensure_response( $item ); + + if ( rest_is_field_included( '_links', $fields ) ) { + $links = $this->prepare_links( $collection ); + $response->add_links( $links ); + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $response->data = $this->add_additional_fields_to_object( $response->data, $request ); + $response->data = $this->filter_response_by_context( $response->data, $context ); + + /** + * Filters a font collection returned from the REST API. + * + * Allows modification of the font collection right before it is returned. + * + * @since 6.5.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Font_Collection $collection The Font Collection object. + * @param WP_REST_Request $request Request used to generate the response. + */ + return apply_filters( 'rest_prepare_font_collection', $response, $collection, $request ); + } + + /** + * Retrieves the font collection's schema, conforming to JSON Schema. + * + * @since 6.5.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'font-collection', + 'type' => 'object', + 'properties' => array( + 'slug' => array( + 'description' => __( 'Unique identifier for the font collection.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'name' => array( + 'description' => __( 'The name for the font collection.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'description' => array( + 'description' => __( 'The description for the font collection.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'font_families' => array( + 'description' => __( 'The font families for the font collection.' ), + 'type' => 'array', + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'categories' => array( + 'description' => __( 'The categories for the font collection.' ), + 'type' => 'array', + 'context' => array( 'view', 'edit', 'embed' ), + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Prepares links for the request. + * + * @since 6.5.0 + * + * @param WP_Font_Collection $collection Font collection data + * @return array Links for the given font collection. + */ + protected function prepare_links( $collection ) { + return array( + 'self' => array( + 'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $collection->slug ) ), + ), + 'collection' => array( + 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), + ), + ); + } + + /** + * Retrieves the search params for the font collections. + * + * @since 6.5.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + $query_params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); + + unset( $query_params['search'] ); + + /** + * Filters REST API collection parameters for the font collections controller. + * + * @since 6.5.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_font_collections_collection_params', $query_params ); + } + + /** + * Checks whether the user has permissions to use the Fonts Collections. + * + * @since 6.5.0 + * + * @return true|WP_Error True if the request has write access for the item, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + if ( current_user_can( 'edit_theme_options' ) ) { + return true; + } + + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to access font collections.' ), + array( + 'status' => rest_authorization_required_code(), + ) + ); + } + } +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php new file mode 100644 index 0000000000000..338c5384c4dc0 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-faces-controller.php @@ -0,0 +1,944 @@ +namespace, + '/' . $this->rest_base, + array( + 'args' => array( + 'font_family_id' => array( + 'description' => __( 'The ID for the parent font family of the font face.' ), + 'type' => 'integer', + 'required' => true, + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'args' => $this->get_create_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + 'args' => array( + 'font_family_id' => array( + 'description' => __( 'The ID for the parent font family of the font face.' ), + 'type' => 'integer', + 'required' => true, + ), + 'id' => array( + 'description' => __( 'Unique identifier for the font face.' ), + 'type' => 'integer', + 'required' => true, + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), + 'args' => array( + 'force' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Whether to bypass Trash and force deletion.', 'default' ), + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks if a given request has access to font faces. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- required by parent class + $post_type = get_post_type_object( $this->post_type ); + + if ( ! current_user_can( $post_type->cap->read ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to access font faces.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Checks if a given request has access to a font face. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + if ( ! current_user_can( 'read_post', $post->ID ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to access this font face.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Validates settings when creating a font face. + * + * @since 6.5.0 + * + * @param string $value Encoded JSON string of font face settings. + * @param WP_REST_Request $request Request object. + * @return true|WP_Error True if the settings are valid, otherwise a WP_Error object. + */ + public function validate_create_font_face_settings( $value, $request ) { + $settings = json_decode( $value, true ); + + // Check settings string is valid JSON. + if ( null === $settings ) { + return new WP_Error( + 'rest_invalid_param', + __( 'font_face_settings parameter must be a valid JSON string.' ), + array( 'status' => 400 ) + ); + } + + // Check that the font face settings match the theme.json schema. + $schema = $this->get_item_schema()['properties']['font_face_settings']; + $has_valid_settings = rest_validate_value_from_schema( $settings, $schema, 'font_face_settings' ); + + if ( is_wp_error( $has_valid_settings ) ) { + $has_valid_settings->add_data( array( 'status' => 400 ) ); + return $has_valid_settings; + } + + // Check that none of the required settings are empty values. + $required = $schema['required']; + foreach ( $required as $key ) { + if ( isset( $settings[ $key ] ) && ! $settings[ $key ] ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: Name of the missing font face settings parameter, e.g. "font_face_settings[src]". */ + sprintf( __( '%s cannot be empty.' ), "font_face_setting[ $key ]" ), + array( 'status' => 400 ) + ); + } + } + + $srcs = is_array( $settings['src'] ) ? $settings['src'] : array( $settings['src'] ); + $files = $request->get_file_params(); + + foreach ( $srcs as $src ) { + // Check that each src is a non-empty string. + $src = ltrim( $src ); + if ( empty( $src ) ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: Font face source parameter name: "font_face_settings[src]". */ + sprintf( __( '%s values must be non-empty strings.' ), 'font_face_settings[src]' ), + array( 'status' => 400 ) + ); + } + + // Check that srcs are valid URLs or file references. + if ( false === wp_http_validate_url( $src ) && ! isset( $files[ $src ] ) ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: 1: Font face source parameter name: "font_face_settings[src]", 2: The invalid src value. */ + sprintf( __( '%1$s value "%2$s" must be a valid URL or file reference.' ), 'font_face_settings[src]', $src ), + array( 'status' => 400 ) + ); + } + } + + // Check that each file in the request references a src in the settings. + foreach ( array_keys( $files ) as $file ) { + if ( ! in_array( $file, $srcs, true ) ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: 1: File key (e.g. "file-0") in the request data, 2: Font face source parameter name: "font_face_settings[src]". */ + sprintf( __( 'File %1$s must be used in %2$s.' ), $file, 'font_face_settings[src]' ), + array( 'status' => 400 ) + ); + } + } + + return true; + } + + /** + * Sanitizes the font face settings when creating a font face. + * + * @since 6.5.0 + * + * @param string $value Encoded JSON string of font face settings. + * @param WP_REST_Request $request Request object. + * @return array Decoded array of font face settings. + */ + public function sanitize_font_face_settings( $value ) { + // Settings arrive as stringified JSON, since this is a multipart/form-data request. + $settings = json_decode( $value, true ); + $schema = $this->get_item_schema()['properties']['font_face_settings']['properties']; + + // Sanitize settings based on callbacks in the schema. + foreach ( $settings as $key => $value ) { + $sanitize_callback = $schema[ $key ]['arg_options']['sanitize_callback']; + $settings[ $key ] = call_user_func( $sanitize_callback, $value ); + } + + return $settings; + } + + /** + * Retrieves a collection of font faces within the parent font family. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + return parent::get_items( $request ); + } + + /** + * Retrieves a single font face within the parent font family. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_item( $request ) { + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + // Check that the font face has a valid parent font family. + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + if ( (int) $font_family->ID !== (int) $post->post_parent ) { + return new WP_Error( + 'rest_font_face_parent_id_mismatch', + /* translators: %d: A post id. */ + sprintf( __( 'The font face does not belong to the specified font family with id of "%d".' ), $font_family->ID ), + array( 'status' => 404 ) + ); + } + + return parent::get_item( $request ); + } + + /** + * Creates a font face for the parent font family. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function create_item( $request ) { + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + // Settings have already been decoded by ::sanitize_font_face_settings(). + $settings = $request->get_param( 'font_face_settings' ); + $file_params = $request->get_file_params(); + + // Check that the necessary font face properties are unique. + $query = new WP_Query( + array( + 'post_type' => $this->post_type, + 'posts_per_page' => 1, + 'title' => WP_Font_Utils::get_font_face_slug( $settings ), + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + if ( ! empty( $query->get_posts() ) ) { + return new WP_Error( + 'rest_duplicate_font_face', + __( 'A font face matching those settings already exists.' ), + array( 'status' => 400 ) + ); + } + + // Move the uploaded font asset from the temp folder to the fonts directory. + if ( ! function_exists( 'wp_handle_upload' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + $srcs = is_string( $settings['src'] ) ? array( $settings['src'] ) : $settings['src']; + $processed_srcs = array(); + $font_file_meta = array(); + + foreach ( $srcs as $src ) { + // If src not a file reference, use it as is. + if ( ! isset( $file_params[ $src ] ) ) { + $processed_srcs[] = $src; + continue; + } + + $file = $file_params[ $src ]; + $font_file = $this->handle_font_file_upload( $file ); + if ( is_wp_error( $font_file ) ) { + return $font_file; + } + + $processed_srcs[] = $font_file['url']; + $font_file_meta[] = $this->relative_fonts_path( $font_file['file'] ); + } + + // Store the updated settings for prepare_item_for_database to use. + $settings['src'] = count( $processed_srcs ) === 1 ? $processed_srcs[0] : $processed_srcs; + $request->set_param( 'font_face_settings', $settings ); + + // Ensure that $settings data is slashed, so values with quotes are escaped. + // WP_REST_Posts_Controller::create_item uses wp_slash() on the post_content. + $font_face_post = parent::create_item( $request ); + + if ( is_wp_error( $font_face_post ) ) { + return $font_face_post; + } + + $font_face_id = $font_face_post->data['id']; + + foreach ( $font_file_meta as $font_file_path ) { + add_post_meta( $font_face_id, '_wp_font_face_file', $font_file_path ); + } + + return $font_face_post; + } + + /** + * Deletes a single font face. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function delete_item( $request ) { + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + if ( (int) $font_family->ID !== (int) $post->post_parent ) { + return new WP_Error( + 'rest_font_face_parent_id_mismatch', + /* translators: %d: A post id. */ + sprintf( __( 'The font face does not belong to the specified font family with id of "%d".' ), $font_family->ID ), + array( 'status' => 404 ) + ); + } + + $force = isset( $request['force'] ) ? (bool) $request['force'] : false; + + // We don't support trashing for font faces. + if ( ! $force ) { + return new WP_Error( + 'rest_trash_not_supported', + /* translators: %s: force=true */ + sprintf( __( "Font faces do not support trashing. Set '%s' to delete." ), 'force=true' ), + array( 'status' => 501 ) + ); + } + + return parent::delete_item( $request ); + } + + /** + * Prepares a single font face output for response. + * + * @since 6.5.0 + * + * @param WP_Post $item Post object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ) { + $fields = $this->get_fields_for_response( $request ); + $data = array(); + + if ( rest_is_field_included( 'id', $fields ) ) { + $data['id'] = $item->ID; + } + if ( rest_is_field_included( 'theme_json_version', $fields ) ) { + $data['theme_json_version'] = 2; + } + + if ( rest_is_field_included( 'parent', $fields ) ) { + $data['parent'] = $item->post_parent; + } + + if ( rest_is_field_included( 'font_face_settings', $fields ) ) { + $data['font_face_settings'] = $this->get_settings_from_post( $item ); + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + $response = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $links = $this->prepare_links( $item ); + $response->add_links( $links ); + } + + /** + * Filters the font face data for a REST API response. + * + * @since 6.5.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $post Font face post object. + * @param WP_REST_Request $request Request object. + */ + return apply_filters( 'rest_prepare_wp_font_face', $response, $item, $request ); + } + + /** + * Retrieves the post's schema, conforming to JSON Schema. + * + * @since 6.5.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => $this->post_type, + 'type' => 'object', + // Base properties for every Post. + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the post.', 'default' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'theme_json_version' => array( + 'description' => __( 'Version of the theme.json schema used for the typography settings.' ), + 'type' => 'integer', + 'default' => 2, + 'minimum' => 2, + 'maximum' => 2, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'parent' => array( + 'description' => __( 'The ID for the parent font family of the font face.' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + ), + // Font face settings come directly from theme.json schema + // See https://schemas.wp.org/trunk/theme.json + 'font_face_settings' => array( + 'description' => __( 'font-face declaration in theme.json format.' ), + 'type' => 'object', + 'context' => array( 'view', 'edit', 'embed' ), + 'properties' => array( + 'fontFamily' => array( + 'description' => __( 'CSS font-family value.' ), + 'type' => 'string', + 'default' => '', + 'arg_options' => array( + 'sanitize_callback' => array( 'WP_Font_Utils', 'sanitize_font_family' ), + ), + ), + 'fontStyle' => array( + 'description' => __( 'CSS font-style value.' ), + 'type' => 'string', + 'default' => 'normal', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'fontWeight' => array( + 'description' => __( 'List of available font weights, separated by a space.' ), + 'default' => '400', + // Changed from `oneOf` to avoid errors from loose type checking. + // e.g. a fontWeight of "400" validates as both a string and an integer due to is_numeric check. + 'type' => array( 'string', 'integer' ), + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'fontDisplay' => array( + 'description' => __( 'CSS font-display value.' ), + 'type' => 'string', + 'default' => 'fallback', + 'enum' => array( + 'auto', + 'block', + 'fallback', + 'swap', + 'optional', + ), + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'src' => array( + 'description' => __( 'Paths or URLs to the font files.' ), + // Changed from `oneOf` to `anyOf` due to rest_sanitize_array converting a string into an array, + // and causing a "matches more than one of the expected formats" error. + 'anyOf' => array( + array( + 'type' => 'string', + ), + array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + ), + 'default' => array(), + 'arg_options' => array( + 'sanitize_callback' => function ( $value ) { + return is_array( $value ) ? array_map( array( $this, 'sanitize_src' ), $value ) : $this->sanitize_src( $value ); + }, + ), + ), + 'fontStretch' => array( + 'description' => __( 'CSS font-stretch value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'ascentOverride' => array( + 'description' => __( 'CSS ascent-override value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'descentOverride' => array( + 'description' => __( 'CSS descent-override value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'fontVariant' => array( + 'description' => __( 'CSS font-variant value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'fontFeatureSettings' => array( + 'description' => __( 'CSS font-feature-settings value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'fontVariationSettings' => array( + 'description' => __( 'CSS font-variation-settings value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'lineGapOverride' => array( + 'description' => __( 'CSS line-gap-override value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'sizeAdjust' => array( + 'description' => __( 'CSS size-adjust value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'unicodeRange' => array( + 'description' => __( 'CSS unicode-range value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'preview' => array( + 'description' => __( 'URL to a preview image of the font face.' ), + 'type' => 'string', + 'format' => 'uri', + 'default' => '', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_url', + ), + ), + ), + 'required' => array( 'fontFamily', 'src' ), + 'additionalProperties' => false, + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Retrieves the item's schema for display / public consumption purposes. + * + * @since 6.5.0 + * + * @return array Public item schema data. + */ + public function get_public_item_schema() { + + $schema = parent::get_public_item_schema(); + + // Also remove `arg_options' from child font_family_settings properties, since the parent + // controller only handles the top level properties. + foreach ( $schema['properties']['font_face_settings']['properties'] as &$property ) { + unset( $property['arg_options'] ); + } + + return $schema; + } + + /** + * Retrieves the query params for the font face collection. + * + * @since 6.5.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + // Remove unneeded params. + unset( $query_params['after'] ); + unset( $query_params['modified_after'] ); + unset( $query_params['before'] ); + unset( $query_params['modified_before'] ); + unset( $query_params['search'] ); + unset( $query_params['search_columns'] ); + unset( $query_params['slug'] ); + unset( $query_params['status'] ); + + $query_params['orderby']['default'] = 'id'; + $query_params['orderby']['enum'] = array( 'id', 'include' ); + + /** + * Filters collection parameters for the font face controller. + * + * @since 6.5.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_wp_font_face_collection_params', $query_params ); + } + + /** + * Get the params used when creating a new font face. + * + * @since 6.5.0 + * + * @return array Font face create arguments. + */ + public function get_create_params() { + $properties = $this->get_item_schema()['properties']; + return array( + 'theme_json_version' => $properties['theme_json_version'], + // When creating, font_face_settings is stringified JSON, to work with multipart/form-data used + // when uploading font files. + 'font_face_settings' => array( + 'description' => __( 'font-face declaration in theme.json format, encoded as a string.' ), + 'type' => 'string', + 'required' => true, + 'validate_callback' => array( $this, 'validate_create_font_face_settings' ), + 'sanitize_callback' => array( $this, 'sanitize_font_face_settings' ), + ), + ); + } + + /** + * Get the parent font family, if the ID is valid. + * + * @since 6.5.0 + * + * @param int $font_family_id Supplied ID. + * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise. + */ + protected function get_parent_font_family_post( $font_family_id ) { + $error = new WP_Error( + 'rest_post_invalid_parent', + __( 'Invalid post parent ID.', 'default' ), + array( 'status' => 404 ) + ); + + if ( (int) $font_family_id <= 0 ) { + return $error; + } + + $font_family_post = get_post( (int) $font_family_id ); + + if ( empty( $font_family_post ) || empty( $font_family_post->ID ) + || 'wp_font_family' !== $font_family_post->post_type + ) { + return $error; + } + + return $font_family_post; + } + + /** + * Prepares links for the request. + * + * @since 6.5.0 + * + * @param WP_Post $post Post object. + * @return array Links for the given post. + */ + protected function prepare_links( $post ) { + // Entity meta. + return array( + 'self' => array( + 'href' => rest_url( $this->namespace . '/font-families/' . $post->post_parent . '/font-faces/' . $post->ID ), + ), + 'collection' => array( + 'href' => rest_url( $this->namespace . '/font-families/' . $post->post_parent . '/font-faces' ), + ), + 'parent' => array( + 'href' => rest_url( $this->namespace . '/font-families/' . $post->post_parent ), + ), + ); + } + + /** + * Prepares a single font face post for creation. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Request object. + * @return stdClass|WP_Error Post object or WP_Error. + */ + protected function prepare_item_for_database( $request ) { + $prepared_post = new stdClass(); + + // Settings have already been decoded by ::sanitize_font_face_settings(). + $settings = $request->get_param( 'font_face_settings' ); + + // Store this "slug" as the post_title rather than post_name, since it uses the fontFamily setting, + // which may contain multibyte characters. + $title = WP_Font_Utils::get_font_face_slug( $settings ); + + $prepared_post->post_type = $this->post_type; + $prepared_post->post_parent = $request['font_family_id']; + $prepared_post->post_status = 'publish'; + $prepared_post->post_title = $title; + $prepared_post->post_name = sanitize_title( $title ); + $prepared_post->post_content = wp_json_encode( $settings ); + + return $prepared_post; + } + + /** + * Sanitizes a single src value for a font face. + * + * @since 6.5.0 + * + * @param string $value Font face src that is a URL or the key for a $_FILES array item. + * + * @return string Sanitized value. + */ + protected function sanitize_src( $value ) { + $value = ltrim( $value ); + return false === wp_http_validate_url( $value ) ? (string) $value : sanitize_url( $value ); + } + + /** + * Handles the upload of a font file using wp_handle_upload(). + * + * @since 6.5.0 + * + * @param array $file Single file item from $_FILES. + * @return array Array containing uploaded file attributes on success, or error on failure. + */ + protected function handle_font_file_upload( $file ) { + add_filter( 'upload_mimes', array( 'WP_Font_Utils', 'get_allowed_font_mime_types' ) ); + add_filter( 'upload_dir', 'wp_get_font_dir' ); + + $overrides = array( + 'upload_error_handler' => array( $this, 'handle_font_file_upload_error' ), + // Arbitrary string to avoid the is_uploaded_file() check applied + // when using 'wp_handle_upload'. + 'action' => 'wp_handle_font_upload', + // Not testing a form submission. + 'test_form' => false, + // Seems mime type for files that are not images cannot be tested. + // See wp_check_filetype_and_ext(). + 'test_type' => true, + // Only allow uploading font files for this request. + 'mimes' => WP_Font_Utils::get_allowed_font_mime_types(), + ); + + $uploaded_file = wp_handle_upload( $file, $overrides ); + + remove_filter( 'upload_dir', 'wp_get_font_dir' ); + remove_filter( 'upload_mimes', array( 'WP_Font_Utils', 'get_allowed_font_mime_types' ) ); + + return $uploaded_file; + } + + /** + * Handles file upload error. + * + * @since 6.5.0 + * + * @param array $file File upload data. + * @param string $message Error message from wp_handle_upload(). + * @return WP_Error WP_Error object. + */ + public function handle_font_file_upload_error( $file, $message ) { + $status = 500; + $code = 'rest_font_upload_unknown_error'; + + if ( __( 'Sorry, you are not allowed to upload this file type.' ) === $message ) { + $status = 400; + $code = 'rest_font_upload_invalid_file_type'; + } + + return new WP_Error( $code, $message, array( 'status' => $status ) ); + } + + /** + * Returns relative path to an uploaded font file. + * + * The path is relative to the current fonts directory. + * + * @since 6.5.0 + * @access private + * + * @param string $path Full path to the file. + * @return string Relative path on success, unchanged path on failure. + */ + protected function relative_fonts_path( $path ) { + $new_path = $path; + + $fonts_dir = wp_get_font_dir(); + if ( str_starts_with( $new_path, $fonts_dir['path'] ) ) { + $new_path = str_replace( $fonts_dir, '', $new_path ); + $new_path = ltrim( $new_path, '/' ); + } + + return $new_path; + } + + /** + * Gets the font face's settings from the post. + * + * @since 6.5.0 + * + * @param WP_Post $post Font face post object. + * @return array Font face settings array. + */ + protected function get_settings_from_post( $post ) { + $settings = json_decode( $post->post_content, true ); + $properties = $this->get_item_schema()['properties']['font_face_settings']['properties']; + + // Provide required, empty settings if needed. + if ( null === $settings ) { + $settings = array( + 'fontFamily' => '', + 'src' => array(), + ); + } + + // Only return the properties defined in the schema. + return array_intersect_key( $settings, $properties ); + } + } +} \ No newline at end of file diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php new file mode 100644 index 0000000000000..a4f5acb1d8075 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php @@ -0,0 +1,557 @@ +post_type ); + + if ( ! current_user_can( $post_type->cap->read ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to access font families.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Checks if a given request has access to a font family. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + if ( ! current_user_can( 'read_post', $post->ID ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to access this font family.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Validates settings when creating or updating a font family. + * + * @since 6.5.0 + * + * @param string $value Encoded JSON string of font family settings. + * @param WP_REST_Request $request Request object. + * @return true|WP_Error True if the settings are valid, otherwise a WP_Error object. + */ + public function validate_font_family_settings( $value, $request ) { + $settings = json_decode( $value, true ); + + // Check settings string is valid JSON. + if ( null === $settings ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: Parameter name: "font_family_settings". */ + sprintf( __( '%s parameter must be a valid JSON string.' ), 'font_family_settings' ), + array( 'status' => 400 ) + ); + } + + $schema = $this->get_item_schema()['properties']['font_family_settings']; + $required = $schema['required']; + + if ( isset( $request['id'] ) ) { + // Allow sending individual properties if we are updating an existing font family. + unset( $schema['required'] ); + + // But don't allow updating the slug, since it is used as a unique identifier. + if ( isset( $settings['slug'] ) ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: Name of parameter being updated: font_family_settings[slug]". */ + sprintf( __( '%s cannot be updated.' ), 'font_family_settings[slug]' ), + array( 'status' => 400 ) + ); + } + } + + // Check that the font face settings match the theme.json schema. + $has_valid_settings = rest_validate_value_from_schema( $settings, $schema, 'font_family_settings' ); + + if ( is_wp_error( $has_valid_settings ) ) { + $has_valid_settings->add_data( array( 'status' => 400 ) ); + return $has_valid_settings; + } + + // Check that none of the required settings are empty values. + foreach ( $required as $key ) { + if ( isset( $settings[ $key ] ) && ! $settings[ $key ] ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: Name of the empty font family setting parameter, e.g. "font_family_settings[slug]". */ + sprintf( __( '%s cannot be empty.' ), "font_family_settings[ $key ]" ), + array( 'status' => 400 ) + ); + } + } + + return true; + } + + /** + * Sanitizes the font family settings when creating or updating a font family. + * + * @since 6.5.0 + * + * @param string $value Encoded JSON string of font family settings. + * @param WP_REST_Request $request Request object. + * @return array Decoded array font family settings. + */ + public function sanitize_font_family_settings( $value ) { + // Settings arrive as stringified JSON, since this is a multipart/form-data request. + $settings = json_decode( $value, true ); + $schema = $this->get_item_schema()['properties']['font_family_settings']['properties']; + + // Sanitize settings based on callbacks in the schema. + foreach ( $settings as $key => $value ) { + $sanitize_callback = $schema[ $key ]['arg_options']['sanitize_callback']; + $settings[ $key ] = call_user_func( $sanitize_callback, $value ); + } + + return $settings; + } + + /** + * Creates a single font family. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function create_item( $request ) { + $settings = $request->get_param( 'font_family_settings' ); + + // Check that the font family slug is unique. + $query = new WP_Query( + array( + 'post_type' => $this->post_type, + 'posts_per_page' => 1, + 'name' => $settings['slug'], + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + if ( ! empty( $query->get_posts() ) ) { + return new WP_Error( + 'rest_duplicate_font_family', + /* translators: %s: Font family slug. */ + sprintf( __( 'A font family with slug "%s" already exists.' ), $settings['slug'] ), + array( 'status' => 400 ) + ); + } + + return parent::create_item( $request ); + } + + /** + * Deletes a single font family. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function delete_item( $request ) { + $force = isset( $request['force'] ) ? (bool) $request['force'] : false; + + // We don't support trashing for font families. + if ( ! $force ) { + return new WP_Error( + 'rest_trash_not_supported', + /* translators: %s: force=true */ + sprintf( __( "Font faces do not support trashing. Set '%s' to delete." ), 'force=true' ), + array( 'status' => 501 ) + ); + } + + return parent::delete_item( $request ); + } + + /** + * Prepares a single font family output for response. + * + * @since 6.5.0 + * + * @param WP_Post $item Post object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ) { + $fields = $this->get_fields_for_response( $request ); + $data = array(); + + if ( rest_is_field_included( 'id', $fields ) ) { + $data['id'] = $item->ID; + } + + if ( rest_is_field_included( 'theme_json_version', $fields ) ) { + $data['theme_json_version'] = 2; + } + + if ( rest_is_field_included( 'font_faces', $fields ) ) { + $data['font_faces'] = $this->get_font_face_ids( $item->ID ); + } + + if ( rest_is_field_included( 'font_family_settings', $fields ) ) { + $data['font_family_settings'] = $this->get_settings_from_post( $item ); + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + $response = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) ) { + $links = $this->prepare_links( $item ); + $response->add_links( $links ); + } + + /** + * Filters the font family data for a REST API response. + * + * @since 6.5.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $post Font family post object. + * @param WP_REST_Request $request Request object. + */ + return apply_filters( 'rest_prepare_wp_font_family', $response, $item, $request ); + } + + /** + * Retrieves the post's schema, conforming to JSON Schema. + * + * @since 6.5.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => $this->post_type, + 'type' => 'object', + // Base properties for every Post. + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the post.', 'default' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'theme_json_version' => array( + 'description' => __( 'Version of the theme.json schema used for the typography settings.' ), + 'type' => 'integer', + 'default' => 2, + 'minimum' => 2, + 'maximum' => 2, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'font_faces' => array( + 'description' => __( 'The IDs of the child font faces in the font family.' ), + 'type' => 'array', + 'context' => array( 'view', 'edit', 'embed' ), + 'items' => array( + 'type' => 'integer', + ), + ), + // Font family settings come directly from theme.json schema + // See https://schemas.wp.org/trunk/theme.json + 'font_family_settings' => array( + 'description' => __( 'font-face definition in theme.json format.' ), + 'type' => 'object', + 'context' => array( 'view', 'edit', 'embed' ), + 'properties' => array( + 'name' => array( + 'description' => __( 'Name of the font family preset, translatable.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'slug' => array( + 'description' => __( 'Kebab-case unique identifier for the font family preset.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_title', + ), + ), + 'fontFamily' => array( + 'description' => __( 'CSS font-family value.' ), + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => array( 'WP_Font_Utils', 'sanitize_font_family' ), + ), + ), + 'preview' => array( + 'description' => __( 'URL to a preview image of the font family.' ), + 'type' => 'string', + 'format' => 'uri', + 'default' => '', + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_url', + ), + ), + ), + 'required' => array( 'name', 'slug', 'fontFamily' ), + 'additionalProperties' => false, + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Retrieves the item's schema for display / public consumption purposes. + * + * @since 6.5.0 + * + * @return array Public item schema data. + */ + public function get_public_item_schema() { + + $schema = parent::get_public_item_schema(); + + // Also remove `arg_options' from child font_family_settings properties, since the parent + // controller only handles the top level properties. + foreach ( $schema['properties']['font_family_settings']['properties'] as &$property ) { + unset( $property['arg_options'] ); + } + + return $schema; + } + + /** + * Retrieves the query params for the font family collection. + * + * @since 6.5.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + // Remove unneeded params. + unset( $query_params['after'] ); + unset( $query_params['modified_after'] ); + unset( $query_params['before'] ); + unset( $query_params['modified_before'] ); + unset( $query_params['search'] ); + unset( $query_params['search_columns'] ); + unset( $query_params['status'] ); + + $query_params['orderby']['default'] = 'id'; + $query_params['orderby']['enum'] = array( 'id', 'include' ); + + /** + * Filters collection parameters for the font family controller. + * + * @since 6.5.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_wp_font_family_collection_params', $query_params ); + } + + /** + * Get the arguments used when creating or updating a font family. + * + * @since 6.5.0 + * + * @return array Font family create/edit arguments. + */ + public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) { + if ( WP_REST_Server::CREATABLE === $method || WP_REST_Server::EDITABLE === $method ) { + $properties = $this->get_item_schema()['properties']; + return array( + 'theme_json_version' => $properties['theme_json_version'], + // When creating or updating, font_family_settings is stringified JSON, to work with multipart/form-data. + // Font families don't currently support file uploads, but may accept preview files in the future. + 'font_family_settings' => array( + 'description' => __( 'font-family declaration in theme.json format, encoded as a string.' ), + 'type' => 'string', + 'required' => true, + 'validate_callback' => array( $this, 'validate_font_family_settings' ), + 'sanitize_callback' => array( $this, 'sanitize_font_family_settings' ), + ), + ); + } + + return parent::get_endpoint_args_for_item_schema( $method ); + } + + /** + * Get the child font face post IDs. + * + * @since 6.5.0 + * + * @param int $font_family_id Font family post ID. + * @return int[] Array of child font face post IDs. + */ + protected function get_font_face_ids( $font_family_id ) { + $query = new WP_Query( + array( + 'fields' => 'ids', + 'post_parent' => $font_family_id, + 'post_type' => 'wp_font_face', + 'posts_per_page' => 99, + 'order' => 'ASC', + 'orderby' => 'id', + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + + return $query->get_posts(); + } + + /** + * Prepares font family links for the request. + * + * @since 6.5.0 + * + * @param WP_Post $post Post object. + * @return array Links for the given post. + */ + protected function prepare_links( $post ) { + // Entity meta. + $links = parent::prepare_links( $post ); + + return array( + 'self' => $links['self'], + 'collection' => $links['collection'], + 'font_faces' => $this->prepare_font_face_links( $post->ID ), + ); + } + + /** + * Prepares child font face links for the request. + * + * @param int $font_family_id Font family post ID. + * @return array Links for the child font face posts. + */ + protected function prepare_font_face_links( $font_family_id ) { + $font_face_ids = $this->get_font_face_ids( $font_family_id ); + $links = array(); + foreach ( $font_face_ids as $font_face_id ) { + $links[] = array( + 'embeddable' => true, + 'href' => rest_url( $this->namespace . '/' . $this->rest_base . '/' . $font_family_id . '/font-faces/' . $font_face_id ), + ); + } + return $links; + } + + /** + * Prepares a single font family post for create or update. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Request object. + * @return stdClass|WP_Error Post object or WP_Error. + */ + protected function prepare_item_for_database( $request ) { + $prepared_post = new stdClass(); + // Settings have already been decoded by ::sanitize_font_family_settings(). + $settings = $request->get_param( 'font_family_settings' ); + + // This is an update and we merge with the existing font family. + if ( isset( $request['id'] ) ) { + $existing_post = $this->get_post( $request['id'] ); + if ( is_wp_error( $existing_post ) ) { + return $existing_post; + } + + $prepared_post->ID = $existing_post->ID; + $existing_settings = $this->get_settings_from_post( $existing_post ); + $settings = array_merge( $existing_settings, $settings ); + } + + $prepared_post->post_type = $this->post_type; + $prepared_post->post_status = 'publish'; + $prepared_post->post_title = $settings['name']; + $prepared_post->post_name = sanitize_title( $settings['slug'] ); + + // Remove duplicate information from settings. + unset( $settings['name'] ); + unset( $settings['slug'] ); + + $prepared_post->post_content = wp_json_encode( $settings ); + + return $prepared_post; + } + + /** + * Gets the font family's settings from the post. + * + * @since 6.5.0 + * + * @param WP_Post $post Font family post object. + * @return array Font family settings array. + */ + protected function get_settings_from_post( $post ) { + $settings_json = json_decode( $post->post_content, true ); + + // Default to empty strings if the settings are missing. + return array( + 'name' => isset( $post->post_title ) && $post->post_title ? $post->post_title : '', + 'slug' => isset( $post->post_name ) && $post->post_name ? $post->post_name : '', + 'fontFamily' => isset( $settings_json['fontFamily'] ) && $settings_json['fontFamily'] ? $settings_json['fontFamily'] : '', + 'preview' => isset( $settings_json['preview'] ) && $settings_json['preview'] ? $settings_json['preview'] : '', + ); + } + } +} \ No newline at end of file diff --git a/src/wp-settings.php b/src/wp-settings.php index 22683b37d1f5d..bdb27aae007d3 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -310,6 +310,9 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-templates-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-url-details-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-navigation-fallback-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-families-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-faces-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-collections-controller.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-comment-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php'; diff --git a/tests/phpunit/tests/fonts/font-library/wpRestFontCollectionsController.php b/tests/phpunit/tests/fonts/font-library/wpRestFontCollectionsController.php new file mode 100644 index 0000000000000..60f50e503fdbe --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpRestFontCollectionsController.php @@ -0,0 +1,203 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + self::$editor_id = $factory->user->create( + array( + 'role' => 'editor', + ) + ); + $mock_file = wp_tempnam( 'my-collection-data-' ); + file_put_contents( $mock_file, '{"name": "Mock Collection", "font_families": [ "mock" ], "categories": [ "mock" ] }' ); + + wp_register_font_collection( 'mock-col-slug', $mock_file ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + wp_unregister_font_collection( 'mock-col-slug' ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertCount( 1, $routes['/wp/v2/font-collections'], 'Rest server has not the collections path initialized.' ); + $this->assertCount( 1, $routes['/wp/v2/font-collections/(?P[\/\w-]+)'], 'Rest server has not the collection path initialized.' ); + + $this->assertArrayHasKey( 'GET', $routes['/wp/v2/font-collections'][0]['methods'], 'Rest server has not the GET method for collections initialized.' ); + $this->assertArrayHasKey( 'GET', $routes['/wp/v2/font-collections/(?P[\/\w-]+)'][0]['methods'], 'Rest server has not the GET method for collection initialized.' ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_items + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections' ); + $response = rest_get_server()->dispatch( $request ); + $content = $response->get_data(); + $this->assertIsArray( $content ); + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_items + */ + public function test_get_items_should_only_return_valid_collections() { + $this->setExpectedIncorrectUsage( 'WP_Font_Collection::load_from_json' ); + + wp_set_current_user( self::$admin_id ); + wp_register_font_collection( 'invalid-collection', 'invalid-collection-file' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections' ); + $response = rest_get_server()->dispatch( $request ); + $content = $response->get_data(); + + wp_unregister_font_collection( 'invalid-collection' ); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertCount( 1, $content, 'The response should only contain valid collections.' ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_item + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/mock-col-slug' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + + $response_data = $response->get_data(); + $this->assertArrayHasKey( 'name', $response_data, 'Response data does not have the name key.' ); + $this->assertArrayHasKey( 'slug', $response_data, 'Response data does not have the slug key.' ); + $this->assertArrayHasKey( 'description', $response_data, 'Response data does not have the description key.' ); + $this->assertArrayHasKey( 'font_families', $response_data, 'Response data does not have the font_families key.' ); + $this->assertArrayHasKey( 'categories', $response_data, 'Response data does not have the categories key.' ); + + $this->assertIsString( $response_data['name'], 'name is not a string.' ); + $this->assertIsString( $response_data['slug'], 'slug is not a string.' ); + $this->assertIsString( $response_data['description'], 'description is not a string.' ); + + $this->assertIsArray( $response_data['font_families'], 'font_families is not an array.' ); + $this->assertIsArray( $response_data['categories'], 'categories is not an array.' ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_item + */ + public function test_get_item_invalid_slug() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/non-existing-collection' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'font_collection_not_found', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_item + */ + public function test_get_item_invalid_collection() { + $this->setExpectedIncorrectUsage( 'WP_Font_Collection::load_from_json' ); + + wp_set_current_user( self::$admin_id ); + $slug = 'invalid-collection'; + wp_register_font_collection( $slug, 'invalid-collection-file' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/' . $slug ); + $response = rest_get_server()->dispatch( $request ); + + wp_unregister_font_collection( $slug ); + + $this->assertErrorResponse( 'font_collection_json_missing', $response, 500, 'When the collection json file is invalid, the response should return an error for "font_collection_json_missing" with 500 status.' ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_item + */ + public function test_get_item_invalid_id_permission() { + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/mock-col-slug' ); + + wp_set_current_user( 0 ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401, 'The response status should be 401 for non-authenticated users.' ); + + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403, 'The response status should be 403 for users without the right permissions.' ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // Controller does not use get_context_param(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_create_item() { + // Controller does not use test_create_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_update_item() { + // Controller does not use test_update_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() { + // Controller does not use test_delete_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() { + // Controller does not use test_prepare_item(). + } + + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/font-collections' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $properties = $data['schema']['properties']; + $this->assertCount( 5, $properties, 'There should be 5 properties in the response data schema.' ); + $this->assertArrayHasKey( 'slug', $properties, 'The slug property should exist in the response data schema.' ); + $this->assertArrayHasKey( 'name', $properties, 'The name property should exist in the response data schema.' ); + $this->assertArrayHasKey( 'description', $properties, 'The description property should exist in the response data schema.' ); + $this->assertArrayHasKey( 'font_families', $properties, 'The slug font_families should exist in the response data schema.' ); + $this->assertArrayHasKey( 'categories', $properties, 'The categories property should exist in the response data schema.' ); + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php b/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php new file mode 100644 index 0000000000000..bbfeaca30707a --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpRestFontFacesController.php @@ -0,0 +1,1067 @@ + '"Open Sans"', + 'fontWeight' => '400', + 'fontStyle' => 'normal', + 'src' => 'https://fonts.gstatic.com/s/open-sans/v30/KFOkCnqEu92Fr1MmgWxPKTM1K9nz.ttf', + ); + + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$font_family_id = Tests_REST_WpRestFontFamiliesController::create_font_family_post(); + self::$other_font_family_id = Tests_REST_WpRestFontFamiliesController::create_font_family_post(); + + self::$font_face_id1 = self::create_font_face_post( + self::$font_family_id, + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '400', + 'fontStyle' => 'normal', + 'src' => home_url( '/wp-content/fonts/open-sans-medium.ttf' ), + ) + ); + self::$font_face_id2 = self::create_font_face_post( + self::$font_family_id, + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '900', + 'fontStyle' => 'normal', + 'src' => home_url( '/wp-content/fonts/open-sans-bold.ttf' ), + ) + ); + + self::$admin_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + self::$editor_id = $factory->user->create( + array( + 'role' => 'editor', + ) + ); + + self::$post_ids_for_cleanup = array(); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + + wp_delete_post( self::$font_family_id, true ); + wp_delete_post( self::$other_font_family_id, true ); + wp_delete_post( self::$font_face_id1, true ); + wp_delete_post( self::$font_face_id2, true ); + } + + public function tear_down() { + foreach ( self::$post_ids_for_cleanup as $post_id ) { + wp_delete_post( $post_id, true ); + } + self::$post_ids_for_cleanup = array(); + parent::tear_down(); + } + + public static function create_font_face_post( $parent_id, $settings = array() ) { + $settings = array_merge( self::$default_settings, $settings ); + $title = WP_Font_Utils::get_font_face_slug( $settings ); + $post_id = self::factory()->post->create( + wp_slash( + array( + 'post_type' => 'wp_font_face', + 'post_status' => 'publish', + 'post_title' => $title, + 'post_name' => sanitize_title( $title ), + 'post_content' => wp_json_encode( $settings ), + 'post_parent' => $parent_id, + ) + ) + ); + + self::$post_ids_for_cleanup[] = $post_id; + + return $post_id; + } + + /** + * @covers WP_REST_Font_Faces_Controller::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( + '/wp/v2/font-families/(?P[\d]+)/font-faces', + $routes, + 'Font faces collection for the given font family does not exist' + ); + $this->assertCount( + 2, + $routes['/wp/v2/font-families/(?P[\d]+)/font-faces'], + 'Font faces collection for the given font family does not have exactly two elements' + ); + $this->assertArrayHasKey( + '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)', + $routes, + 'Single font face route for the given font family does not exist' + ); + $this->assertCount( + 2, + $routes['/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)'], + 'Font faces collection for the given font family does not have exactly two elements' + ); + } + + public function test_font_faces_no_autosave_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayNotHasKey( + '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)/autosaves', + $routes, + 'Font faces autosaves route exists.' + ); + $this->assertArrayNotHasKey( + '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)/autosaves/(?P[\d]+)', + $routes, + 'Font faces autosaves by id route exists.' + ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // See test_get_context_param(). + } + + /** + * @dataProvider data_get_context_param + * + * @covers WP_REST_Font_Faces_Controller::get_context_param + * + * @param bool $single_route Whether to test a single route. + */ + public function test_get_context_param( $single_route ) { + $route = '/wp/v2/font-families/' . self::$font_family_id . '/font-faces'; + if ( $single_route ) { + $route .= '/' . self::$font_face_id1; + } + + $request = new WP_REST_Request( 'OPTIONS', $route ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $endpoint_data = $data['endpoints'][0]; + $this->assertArrayNotHasKey( 'allow_batch', $endpoint_data, 'The allow_batch property should not exist in the endpoint data.' ); + $this->assertSame( 'view', $endpoint_data['args']['context']['default'], 'The endpoint\'s args::context::default should be set to view.' ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $endpoint_data['args']['context']['enum'], 'The endpoint\'s args::context::enum should be set to [ view, embed, edit ].' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_context_param() { + return array( + 'Collection' => array( false ), + 'Single' => array( true ), + ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_items + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200' ); + $this->assertCount( 2, $data, 'There should be 2 properties in the response data.' ); + $this->assertArrayHasKey( '_links', $data[0], 'The _links property should exist in the response data 0.' ); + $this->check_font_face_data( $data[0], self::$font_face_id2, $data[0]['_links'] ); + $this->assertArrayHasKey( '_links', $data[1], 'The _links property should exist in the response data 1.' ); + $this->check_font_face_data( $data[1], self::$font_face_id1, $data[1]['_links'] ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_items + */ + public function test_get_items_no_permission() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401, 'The response should return an error with a "rest_cannot_read" code and 401 status.' ); + + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403, 'The response should return an error with a "rest_cannot_read" code and 403 status.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_items + */ + public function test_get_items_missing_parent() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER . '/font-faces' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->check_font_face_data( $data, self::$font_face_id1, $response->get_links() ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::prepare_item_for_response + */ + public function test_get_item_removes_extra_settings() { + $font_face_id = self::create_font_face_post( self::$font_family_id, array( 'extra' => array() ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertArrayHasKey( 'font_face_settings', $data, 'The font_face_settings property should exist in the response data.' ); + $this->assertArrayNotHasKey( 'extra', $data['font_face_settings'], 'The extra property should exist in the font_face_settings data.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::prepare_item_for_response + */ + public function test_get_item_malformed_post_content_returns_empty_settings() { + $font_face_id = wp_insert_post( + array( + 'post_type' => 'wp_font_face', + 'post_parent' => self::$font_family_id, + 'post_status' => 'publish', + 'post_content' => 'invalid', + ) + ); + + self::$post_ids_for_cleanup[] = $font_face_id; + + $empty_settings = array( + 'fontFamily' => '', + 'src' => array(), + ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertArrayHasKey( 'font_face_settings', $data, 'The font_face_settings property should exist in the response data.' ); + $this->assertSame( $empty_settings, $data['font_face_settings'], 'The empty settings should exist in the font_face_settings data.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_invalid_font_face_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_no_permission() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id1 ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401, 'The response should return an error with a "rest_cannot_read" code and 401 status.' ); + + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403, 'The response should return an error with a "rest_cannot_read" code and 403 status.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_missing_parent() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_valid_parent_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertSame( self::$font_family_id, $data['parent'], 'The returned parent id should match the font family id.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_invalid_parent_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$other_font_family_id . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_font_face_parent_id_mismatch', $response, 404 ); + + $expected_message = 'The font face does not belong to the specified font family with id of "' . self::$other_font_family_id . '"'; + $this->assertSame( $expected_message, $response->as_error()->get_error_messages()[0], 'The message must contain the correct parent ID.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item() { + wp_set_current_user( self::$admin_id ); + $files = $this->setup_font_file_upload( array( 'woff2' ) ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'normal', + 'src' => array_keys( $files )[0], + ) + ) + ); + $request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->check_font_face_data( $data, $data['id'], $response->get_links() ); + $this->check_file_meta( $data['id'], array( $data['font_face_settings']['src'] ) ); + + $settings = $data['font_face_settings']; + unset( $settings['src'] ); + $this->assertSame( + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'normal', + ), + $settings, + 'The font_face_settings data should match the expected data.' + ); + + $this->assertSame( self::$font_family_id, $data['parent'], 'The returned parent id should match the font family id.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_with_multiple_font_files() { + wp_set_current_user( self::$admin_id ); + $files = $this->setup_font_file_upload( array( 'ttf', 'otf', 'woff', 'woff2' ) ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'normal', + 'src' => array_keys( $files ), + ) + ) + ); + $request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->check_font_face_data( $data, $data['id'], $response->get_links() ); + $this->check_file_meta( $data['id'], $data['font_face_settings']['src'] ); + + $settings = $data['font_face_settings']; + $this->assertCount( 4, $settings['src'], 'There should be 4 items in the font_face_settings::src data.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_invalid_file_type() { + $image_file = DIR_TESTDATA . '/images/canola.jpg'; + $image_path = wp_tempnam( 'canola.jpg' ); + copy( $image_file, $image_path ); + + $files = array( + 'file-0' => array( + 'name' => 'canola.jpg', + 'full_path' => 'canola.jpg', + 'type' => 'font/woff2', + 'tmp_name' => $image_path, + 'error' => 0, + 'size' => filesize( $image_path ), + ), + ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array_merge( + self::$default_settings, + array( + 'fontWeight' => '200', + 'src' => array_keys( $files )[0], + ) + ) + ) + ); + $request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_font_upload_invalid_file_type', $response, 400 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_with_url_src() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'normal', + 'src' => 'https://fonts.gstatic.com/s/open-sans/v30/KFOkCnqEu92Fr1MmgWxPKTM1K9nz.ttf', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->check_font_face_data( $data, $data['id'], $response->get_links() ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_with_all_properties() { + wp_set_current_user( self::$admin_id ); + + $properties = array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '300 500', + 'fontStyle' => 'oblique 30deg 50deg', + 'fontDisplay' => 'swap', + 'fontStretch' => 'expanded', + 'ascentOverride' => '70%', + 'descentOverride' => '30%', + 'fontVariant' => 'normal', + 'fontFeatureSettings' => '"swsh" 2', + 'fontVariationSettings' => '"xhgt" 0.7', + 'lineGapOverride' => '10%', + 'sizeAdjust' => '90%', + 'unicodeRange' => 'U+0025-00FF, U+4??', + 'preview' => 'https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-normal.svg', + 'src' => 'https://fonts.gstatic.com/s/open-sans/v30/KFOkCnqEu92Fr1MmgWxPKTM1K9nz.ttf', + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_face_settings', wp_json_encode( $properties ) ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + wp_delete_post( $data['id'], true ); + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->assertArrayHasKey( 'font_face_settings', $data, 'The font_face_settings property should exist in the response data.' ); + $this->assertSame( $properties, $data['font_face_settings'], 'The font_face_settings should match the expected properties.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_missing_parent() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER . '/font-faces' ); + $request->set_param( + 'font_face_settings', + wp_json_encode( array_merge( self::$default_settings, array( 'fontWeight' => '100' ) ) ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_with_duplicate_properties() { + $settings = array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'italic', + 'src' => home_url( '/wp-content/fonts/open-sans-italic-light.ttf' ), + ); + self::create_font_face_post( self::$font_family_id, $settings ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'font_face_settings', wp_json_encode( $settings ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_duplicate_font_face', $response, 400, 'The response should return an error for "rest_duplicate_font_face" with 400 status.' ); + $expected_message = 'A font face matching those settings already exists.'; + $message = $response->as_error()->get_error_messages()[0]; + $this->assertSame( $expected_message, $message, 'The response error message should match.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_request + */ + public function test_create_item_default_theme_json_version() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'src' => 'https://fonts.gstatic.com/s/open-sans/v30/KFOkCnqEu92Fr1MmgWxPKTM1K9nz.ttf', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + wp_delete_post( $data['id'], true ); + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->assertArrayHasKey( 'theme_json_version', $data, 'The theme_json_version property should exist in the response data.' ); + $this->assertSame( 2, $data['theme_json_version'], 'The default theme.json version should be 2.' ); + } + + /** + * @dataProvider data_create_item_invalid_theme_json_version + * + * @covers WP_REST_Font_Faces_Controller::create_item + * + * @param int $theme_json_version Version input to test. + */ + public function test_create_item_invalid_theme_json_version( $theme_json_version ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', $theme_json_version ); + $request->set_param( 'font_face_settings', '' ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_create_item_invalid_theme_json_version() { + return array( + array( 1 ), + array( 3 ), + ); + } + + /** + * @dataProvider data_create_item_invalid_settings + * + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_settings + * + * @param mixed $settings Settings to test. + */ + public function test_create_item_invalid_settings( $settings ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_face_settings', wp_json_encode( $settings ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_create_item_invalid_settings() { + return array( + 'Missing fontFamily' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'fontFamily' => '' ) ), + ), + 'Empty fontFamily' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontFamily' => '' ) ), + ), + 'Wrong fontFamily type' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontFamily' => 1234 ) ), + ), + 'Invalid fontDisplay' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontDisplay' => 'invalid' ) ), + ), + 'Missing src' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'src' => '' ) ), + ), + 'Empty src string' => array( + 'settings' => array_merge( self::$default_settings, array( 'src' => '' ) ), + ), + 'Empty src array' => array( + 'settings' => array_merge( self::$default_settings, array( 'src' => array() ) ), + ), + 'Empty src array values' => array( + 'settings' => array_merge( self::$default_settings, array( '', '' ) ), + ), + 'Wrong src type' => array( + 'settings' => array_merge( self::$default_settings, array( 'src' => 1234 ) ), + ), + 'Wrong src array types' => array( + 'settings' => array_merge( self::$default_settings, array( 'src' => array( 1234, 5678 ) ) ), + ), + ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_settings + */ + public function test_create_item_invalid_settings_json() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_face_settings', 'invalid' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400, 'The response should return an error for "rest_invalid_param" with 400 status.' ); + $expected_message = 'font_face_settings parameter must be a valid JSON string.'; + $message = $response->as_error()->get_all_error_data()[0]['params']['font_face_settings']; + $this->assertSame( $expected_message, $message, 'The response error message should match.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_settings + */ + public function test_create_item_invalid_file_src() { + $files = $this->setup_font_file_upload( array( 'woff2' ) ); + + wp_set_current_user( self::$admin_id ); + $src = 'invalid'; + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array_merge( self::$default_settings, array( 'src' => $src ) ) + ) + ); + $request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400, 'The response should return an error for "rest_invalid_param" with 400 status.' ); + $expected_message = 'font_face_settings[src] value "' . $src . '" must be a valid URL or file reference.'; + $message = $response->as_error()->get_all_error_data()[0]['params']['font_face_settings']; + $this->assertSame( $expected_message, $message, 'The response error message should match.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_settings + */ + public function test_create_item_missing_file_src() { + $files = $this->setup_font_file_upload( array( 'woff2', 'woff' ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array_merge( self::$default_settings, array( 'src' => array( array_keys( $files )[0] ) ) ) + ) + ); + $request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400, 'The response should return an error for "rest_invalid_param" with 400 status.' ); + $expected_message = 'File ' . array_keys( $files )[1] . ' must be used in font_face_settings[src].'; + $message = $response->as_error()->get_all_error_data()[0]['params']['font_face_settings']; + $this->assertSame( $expected_message, $message, 'The response error message should match.' ); + } + + /** + * @dataProvider data_sanitize_font_face_settings + * + * @covers WP_REST_Font_Face_Controller::sanitize_font_face_settings + * + * @param string $settings Settings to test. + * @param string $expected Expected settings result. + */ + public function test_create_item_sanitize_font_face_settings( $settings, $expected ) { + $settings = array_merge( self::$default_settings, $settings ); + $expected = array_merge( self::$default_settings, $expected ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'font_face_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + wp_delete_post( $data['id'], true ); + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->assertSame( $expected, $data['font_face_settings'], 'The response font_face_settings should match.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_sanitize_font_face_settings() { + return array( + 'settings with tags, extra whitespace, new lines' => array( + 'settings' => array( + 'fontFamily' => " Open Sans\n ", + 'fontStyle' => " oblique 20deg 50deg\n ", + 'fontWeight' => " 200\n ", + 'src' => " https://example.com/ ", + 'fontStretch' => " expanded\n ", + 'ascentOverride' => " 70%\n ", + 'descentOverride' => " 30%\n ", + 'fontVariant' => " normal\n ", + 'fontFeatureSettings' => " \"swsh\" 2\n ", + 'fontVariationSettings' => " \"xhgt\" 0.7\n ", + 'lineGapOverride' => " 10%\n ", + 'sizeAdjust' => " 90%\n ", + 'unicodeRange' => " U+0025-00FF, U+4??\n ", + 'preview' => " https://example.com/ ", + ), + 'expected' => array( + 'fontFamily' => '"Open Sans"', + 'fontStyle' => 'oblique 20deg 50deg', + 'fontWeight' => '200', + 'src' => 'https://example.com//stylescriptalert(\'XSS\');/script%20%20%20%20%20%20', + 'fontStretch' => 'expanded', + 'ascentOverride' => '70%', + 'descentOverride' => '30%', + 'fontVariant' => 'normal', + 'fontFeatureSettings' => '"swsh" 2', + 'fontVariationSettings' => '"xhgt" 0.7', + 'lineGapOverride' => '10%', + 'sizeAdjust' => '90%', + 'unicodeRange' => 'U+0025-00FF, U+4??', + 'preview' => 'https://example.com//stylescriptalert(\'XSS\');/script%20%20%20%20%20%20', + ), + ), + 'multiword font family name with integer' => array( + 'settings' => array( + 'fontFamily' => 'Libre Barcode 128 Text', + ), + 'expected' => array( + 'fontFamily' => '"Libre Barcode 128 Text"', + ), + ), + 'multiword font family name' => array( + 'settings' => array( + 'fontFamily' => 'B612 Mono', + ), + 'expected' => array( + 'fontFamily' => '"B612 Mono"', + ), + ), + 'comma-separated font family names' => array( + 'settings' => array( + 'fontFamily' => 'Open Sans, Noto Sans, sans-serif', + ), + 'expected' => array( + 'fontFamily' => '"Open Sans", "Noto Sans", sans-serif', + ), + ), + ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + // public function test_create_item_no_permission() {} + + /** + * @covers WP_REST_Font_Faces_Controller::update_item + */ + public function test_update_item() { + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_no_route', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item() { + wp_set_current_user( self::$admin_id ); + $font_face_id = self::create_font_face_post( self::$font_family_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 201.' ); + $this->assertNull( get_post( $font_face_id ), 'The deleted post should not exist.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item_no_trash() { + wp_set_current_user( self::$admin_id ); + $font_face_id = self::create_font_face_post( self::$font_family_id ); + + // Attempt trashing. + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501, 'The response should return an error for "rest_trash_not_supported" with 501 status.' ); + + $request->set_param( 'force', 'false' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501, 'When "force" is false, the response should return an error for "rest_trash_not_supported" with 501 status.' ); + + // Ensure the post still exists. + $post = get_post( $font_face_id ); + $this->assertNotEmpty( $post, 'The post should still exists.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item_invalid_font_face_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete + */ + public function test_delete_item_missing_parent() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER . '/font-faces/' . self::$font_face_id1 ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_delete_item_invalid_parent_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$other_font_family_id . '/font-faces/' . self::$font_face_id1 ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_font_face_parent_id_mismatch', $response, 404, 'The response should return an error for "rest_font_face_parent_id_mismatch" with 404 status.' ); + + $expected_message = 'The font face does not belong to the specified font family with id of "' . self::$other_font_family_id . '"'; + $this->assertSame( $expected_message, $response->as_error()->get_error_messages()[0], 'The message must contain the correct parent ID.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item_no_permissions() { + $font_face_id = $this->create_font_face_post( self::$font_family_id ); + + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, 401, 'The response should return an error for "rest_cannot_delete" with 401 status for an invalid user.' ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403, 'The response should return an error for "rest_cannot_delete" with 403 status for a user without permission.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::prepare_item_for_response + */ + public function test_prepare_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id2 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->check_font_face_data( $data, self::$font_face_id2, $response->get_links() ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item_schema + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $properties = $data['schema']['properties']; + $this->assertCount( 4, $properties, 'There should be 4 properties in the schema::properties data.' ); + $this->assertArrayHasKey( 'id', $properties, 'The id property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'theme_json_version', $properties, 'The id property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'parent', $properties, 'The id property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'font_face_settings', $properties, 'The id property should exist in the schema::properties data.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item_schema + */ + public function test_get_item_schema_font_face_settings_should_all_have_sanitize_callbacks() { + $schema = ( new WP_REST_Font_Faces_Controller( 'wp_font_face' ) )->get_item_schema(); + $font_face_settings_schema = $schema['properties']['font_face_settings']; + + $this->assertArrayHasKey( 'properties', $font_face_settings_schema, 'font_face_settings schema is missing properties.' ); + $this->assertIsArray( $font_face_settings_schema['properties'], 'font_face_settings properties should be an array.' ); + + // arg_options should be removed for each setting property. + foreach ( $font_face_settings_schema['properties'] as $property ) { + $this->assertArrayHasKey( 'arg_options', $property, 'Setting schema should have arg_options.' ); + $this->assertArrayHasKey( 'sanitize_callback', $property['arg_options'], 'Setting schema should have a sanitize_callback.' ); + $this->assertIsCallable( $property['arg_options']['sanitize_callback'], 'The sanitize_callback value should be callable.' ); + } + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_public_item_schema + */ + public function test_get_public_item_schema_should_not_have_arg_options() { + $schema = ( new WP_REST_Font_Faces_Controller( 'wp_font_face' ) )->get_public_item_schema(); + $font_face_settings_schema = $schema['properties']['font_face_settings']; + + $this->assertArrayHasKey( 'properties', $font_face_settings_schema, 'font_face_settings schema is missing properties.' ); + $this->assertIsArray( $font_face_settings_schema['properties'], 'font_face_settings properties should be an array.' ); + + // arg_options should be removed for each setting property. + foreach ( $font_face_settings_schema['properties'] as $property ) { + $this->assertArrayNotHasKey( 'arg_options', $property, 'arg_options should be removed from the schema for each setting.' ); + } + } + + protected function check_font_face_data( $data, $post_id, $links ) { + self::$post_ids_for_cleanup[] = $post_id; + $post = get_post( $post_id ); + + $this->assertArrayHasKey( 'id', $data, 'The id property should exist in response data.' ); + $this->assertSame( $post->ID, $data['id'], 'The "id" from the response data should match the post ID.' ); + + $this->assertArrayHasKey( 'parent', $data, 'The parent property should exist in response data.' ); + $this->assertSame( $post->post_parent, $data['parent'], 'The "parent" from the response data should match the post parent.' ); + + $this->assertArrayHasKey( 'theme_json_version', $data, 'The theme_json_version property should exist in response data.' ); + $this->assertSame( WP_Theme_JSON::LATEST_SCHEMA, $data['theme_json_version'], 'The "theme_json_version" from the response data should match WP_Theme_JSON::LATEST_SCHEMA.' ); + + $this->assertArrayHasKey( 'font_face_settings', $data, 'The font_face_settings property should exist in response data.' ); + $this->assertSame( $post->post_content, wp_json_encode( $data['font_face_settings'] ), 'The encoded "font_face_settings" from the response data should match the post content.' ); + + $this->assertNotEmpty( $links, 'The links should not be empty in the response data.' ); + $expected = rest_url( 'wp/v2/font-families/' . $post->post_parent . '/font-faces/' . $post->ID ); + $this->assertSame( $expected, $links['self'][0]['href'], 'The links URL from the response data should match the post\'s REST endpoint.' ); + $expected = rest_url( 'wp/v2/font-families/' . $post->post_parent . '/font-faces' ); + $this->assertSame( $expected, $links['collection'][0]['href'], 'The links collection URL from the response data should match the REST endpoint.' ); + $expected = rest_url( 'wp/v2/font-families/' . $post->post_parent ); + $this->assertSame( $expected, $links['parent'][0]['href'], 'The links for a parent URL from the response data should match the parent\'s REST endpoint.' ); + } + + protected function check_file_meta( $font_face_id, $src_attributes ) { + $file_meta = get_post_meta( $font_face_id, '_wp_font_face_file' ); + + foreach ( $src_attributes as $src_attribute ) { + $file_name = basename( $src_attribute ); + $this->assertContains( $file_name, $file_meta, 'The uploaded font file path should be saved in the post meta.' ); + } + } + + protected function setup_font_file_upload( $formats ) { + $files = array(); + foreach ( $formats as $format ) { + $font_file = DIR_TESTDATA . 'fonts/OpenSans-Regular.' . $format; + $font_path = wp_tempnam( 'OpenSans-Regular.' . $format ); + copy( $font_file, $font_path ); + + $files[ 'file-' . count( $files ) ] = array( + 'name' => 'OpenSans-Regular.' . $format, + 'full_path' => 'OpenSans-Regular.' . $format, + 'type' => 'font/' . $format, + 'tmp_name' => $font_path, + 'error' => 0, + 'size' => filesize( $font_path ), + ); + } + + return $files; + } +} diff --git a/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php b/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php new file mode 100644 index 0000000000000..94ad5eccd7e57 --- /dev/null +++ b/tests/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php @@ -0,0 +1,1050 @@ + 'Open Sans', + 'slug' => 'open-sans', + 'fontFamily' => '"Open Sans", sans-serif', + 'preview' => 'https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-normal.svg', + ); + + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$admin_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + self::$editor_id = $factory->user->create( + array( + 'role' => 'editor', + ) + ); + + self::$font_family_id1 = self::create_font_family_post( + array( + 'name' => 'Open Sans', + 'slug' => 'open-sans', + 'fontFamily' => '"Open Sans", sans-serif', + 'preview' => 'https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-normal.svg', + ) + ); + self::$font_family_id2 = self::create_font_family_post( + array( + 'name' => 'Helvetica', + 'slug' => 'helvetica', + 'fontFamily' => 'Helvetica, Arial, sans-serif', + ) + ); + self::$font_face_id1 = Tests_REST_WpRestFontFacesController::create_font_face_post( + self::$font_family_id1, + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '400', + 'fontStyle' => 'normal', + 'src' => home_url( '/wp-content/fonts/open-sans-medium.ttf' ), + ) + ); + self::$font_face_id2 = Tests_REST_WpRestFontFacesController::create_font_face_post( + self::$font_family_id1, + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '900', + 'fontStyle' => 'normal', + 'src' => home_url( '/wp-content/fonts/open-sans-bold.ttf' ), + ) + ); + + static::$post_ids_to_cleanup = array(); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + + wp_delete_post( self::$font_family_id1 ); + wp_delete_post( self::$font_family_id2 ); + wp_delete_post( self::$font_face_id1 ); + wp_delete_post( self::$font_face_id2 ); + } + + public function tear_down() { + foreach ( static::$post_ids_to_cleanup as $post_id ) { + wp_delete_post( $post_id, true ); + } + static::$post_ids_to_cleanup = array(); + + parent::tear_down(); + } + + public static function create_font_family_post( $settings = array() ) { + $settings = array_merge( self::$default_settings, $settings ); + $post_id = self::factory()->post->create( + wp_slash( + array( + 'post_type' => 'wp_font_family', + 'post_status' => 'publish', + 'post_title' => $settings['name'], + 'post_name' => $settings['slug'], + 'post_content' => wp_json_encode( + array( + 'fontFamily' => $settings['fontFamily'], + 'preview' => $settings['preview'], + ) + ), + ) + ) + ); + + static::$post_ids_to_cleanup[] = $post_id; + + return $post_id; + } + + /** + * @covers WP_REST_Font_Families_Controller::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( + '/wp/v2/font-families', + $routes, + 'Font faces collection for the given font family does not exist' + ); + $this->assertCount( + 2, + $routes['/wp/v2/font-families'], + 'Font faces collection for the given font family does not have exactly two elements' + ); + $this->assertArrayHasKey( + '/wp/v2/font-families/(?P[\d]+)', + $routes, + 'Single font face route for the given font family does not exist' + ); + $this->assertCount( + 3, + $routes['/wp/v2/font-families/(?P[\d]+)'], + 'Font faces collection for the given font family does not have exactly two elements' + ); + } + + public function test_font_families_no_autosave_routes() { + // @core-merge: Enable this test. + $this->markTestSkipped( 'This test only works with WP 6.4 and above. Enable it once 6.5 is released.' ); + $routes = rest_get_server()->get_routes(); + $this->assertArrayNotHasKey( + '/wp/v2/font-families/(?P[\d]+)/autosaves', + $routes, + 'Font families autosaves route exists.' + ); + $this->assertArrayNotHasKey( + '/wp/v2/font-families/(?P[\d]+)/autosaves/(?P[\d]+)', + $routes, + 'Font families autosaves by id route exists.' + ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // See test_get_context_param(). + } + + /** + * @dataProvider data_get_context_param + * + * @covers WP_REST_Font_Families_Controller::get_context_param + * + * @param bool $single_route Whether to test a single route. + */ + public function test_get_context_param( $single_route ) { + $route = '/wp/v2/font-families'; + if ( $single_route ) { + $route .= '/' . self::$font_family_id1; + } + + $request = new WP_REST_Request( 'OPTIONS', $route ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $endpoint_data = $data['endpoints'][0]; + $this->assertArrayNotHasKey( 'allow_batch', $endpoint_data, 'The allow_batch property should not exist in the endpoint data.' ); + $this->assertSame( 'view', $endpoint_data['args']['context']['default'], 'The endpoint\'s args::context::default should be set to view.' ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $endpoint_data['args']['context']['enum'], 'The endpoint\'s args::context::enum should be set to [ view, embed, edit ].' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_context_param() { + return array( + 'Collection' => array( false ), + 'Single' => array( true ), + ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_items + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertCount( 2, $data, 'There should be 2 properties in the response data.' ); + $this->assertArrayHasKey( '_links', $data[0], 'The _links property should exist in the response data 0.' ); + $this->check_font_family_data( $data[0], self::$font_family_id2, $data[0]['_links'] ); + $this->assertArrayHasKey( '_links', $data[1], 'The _links property should exist in the response data 1.' ); + $this->check_font_family_data( $data[1], self::$font_family_id1, $data[1]['_links'] ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_items + */ + public function test_get_items_by_slug() { + $font_family = get_post( self::$font_family_id2 ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families' ); + $request->set_param( 'slug', $font_family->post_name ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertCount( 1, $data, 'There should be 2 properties in the response data.' ); + $this->assertArrayHasKey( 'id', $data[0], 'The id property should exist in the response data.' ); + $this->assertSame( $font_family->ID, $data[0]['id'], 'The id should match the expected ID in the response data.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_items + */ + public function test_get_items_no_permission() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401, 'The response should return an error with a "rest_cannot_read" code and 401 status.' ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403, 'The response should return an error with a "rest_cannot_read" code and 403 status.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_item + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id1 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->check_font_family_data( $data, self::$font_family_id1, $response->get_links() ); + } + + /** + * @covers WP_REST_Font_Families_Controller::prepare_item_for_response + */ + public function test_get_item_embedded_font_faces() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( '_embed', true ); + $response = rest_get_server()->dispatch( $request ); + $data = rest_get_server()->response_to_data( $response, true ); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertArrayHasKey( '_embedded', $data, 'The _embedded property should exist in the response data.' ); + $this->assertArrayHasKey( 'font_faces', $data['_embedded'], 'The font_faces property should exist in _embedded data.' ); + $this->assertCount( 2, $data['_embedded']['font_faces'], 'There should be 2 font_faces in the _embedded data.' ); + + foreach ( $data['_embedded']['font_faces'] as $font_face ) { + $this->assertArrayHasKey( 'id', $font_face, 'The id property should exist in the _embedded font_face data.' ); + + $font_face_request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id1 . '/font-faces/' . $font_face['id'] ); + $font_face_response = rest_get_server()->dispatch( $font_face_request ); + $font_face_data = rest_get_server()->response_to_data( $font_face_response, true ); + + $this->assertSame( $font_face_data, $font_face, 'The embedded font_face data should match when the data from a single request.' ); + } + } + + /** + * @covers WP_REST_Font_Families_Controller::get_item + */ + public function test_get_item_removes_extra_settings() { + $font_family_id = self::create_font_family_post( array( 'fontFace' => array() ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertArrayNotHasKey( 'fontFace', $data['font_family_settings'], 'The fontFace property should not exist in the font_family_settings data.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::prepare_item_for_response + */ + public function test_get_item_malformed_post_content_returns_empty_settings() { + $font_family_id = wp_insert_post( + array( + 'post_type' => 'wp_font_family', + 'post_status' => 'publish', + 'post_content' => 'invalid', + ) + ); + + static::$post_ids_to_cleanup[] = $font_family_id; + + $empty_settings = array( + 'name' => '', + // Slug will default to the post id. + 'slug' => (string) $font_family_id, + 'fontFamily' => '', + 'preview' => '', + ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertSame( $empty_settings, $data['font_family_settings'], 'The empty settings should exist in the font_family_settings data.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_item + */ + public function test_get_item_invalid_font_family_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_item + */ + public function test_get_item_no_permission() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id1 ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401, 'The response should return an error with a "rest_cannot_read" code and 401 status.' ); + + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403, 'The response should return an error with a "rest_cannot_read" code and 403 status.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::create_item + */ + public function test_create_item() { + $settings = array_merge( self::$default_settings, array( 'slug' => 'open-sans-2' ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->check_font_family_data( $data, $data['id'], $response->get_links() ); + + $reponse_settings = $data['font_family_settings']; + $this->assertSame( $settings, $reponse_settings, 'The expected settings should exist in the font_family_settings data.' ); + $this->assertEmpty( $data['font_faces'], 'The font_faces should be empty or not exist in the response data.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::validate_create_font_face_request + */ + public function test_create_item_default_theme_json_version() { + $settings = array_merge( self::$default_settings, array( 'slug' => 'open-sans-2' ) ); + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + static::$post_ids_to_cleanup[] = $data['id']; + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->assertArrayHasKey( 'theme_json_version', $data, 'The theme_json_version property should exist in the response data.' ); + $this->assertSame( 2, $data['theme_json_version'], 'The default theme.json version should be 2.' ); + } + + /** + * @dataProvider data_create_item_invalid_theme_json_version + * + * @covers WP_REST_Font_Families_Controller::create_item + * + * @param int $theme_json_version Version to test. + */ + public function test_create_item_invalid_theme_json_version( $theme_json_version ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', $theme_json_version ); + $request->set_param( 'font_family_settings', wp_json_encode( self::$default_settings ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_create_item_invalid_theme_json_version() { + return array( + array( 1 ), + array( 3 ), + ); + } + + /** + * @dataProvider data_create_item_with_default_preview + * + * @covers WP_REST_Font_Families_Controller::sanitize_font_family_settings + * + * @param array $settings Settings to test. + */ + public function test_create_item_with_default_preview( $settings ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + static::$post_ids_to_cleanup[] = $data['id']; + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $response_settings = $data['font_family_settings']; + $this->assertArrayHasKey( 'preview', $response_settings, 'The preview property should exist in the font_family_settings data.' ); + $this->assertSame( '', $response_settings['preview'], 'The preview data should be an empty string.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_create_item_with_default_preview() { + $default_settings = array( + 'name' => 'Open Sans', + 'slug' => 'open-sans-2', + 'fontFamily' => '"Open Sans", sans-serif', + ); + return array( + 'No preview param' => array( + 'settings' => $default_settings, + ), + 'Empty preview' => array( + 'settings' => array_merge( $default_settings, array( 'preview' => '' ) ), + ), + ); + } + + /** + * @dataProvider data_sanitize_font_family_settings + * + * @covers WP_REST_Font_Families_Controller::sanitize_font_family_settings + * + * @param string $settings Font family settings to test. + * @param string $expected Expected settings result. + */ + public function test_create_item_santize_font_family_settings( $settings, $expected ) { + $settings = array_merge( self::$default_settings, $settings ); + $expected = array_merge( self::$default_settings, $expected ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + static::$post_ids_to_cleanup[] = $data['id']; + + $this->assertSame( 201, $response->get_status(), 'The response status should be 201.' ); + $this->assertSame( $expected, $data['font_family_settings'], 'The response font_family_settings should match.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_sanitize_font_family_settings() { + return array( + 'settings with tags, extra whitespace, new lines' => array( + 'settings' => array( + 'name' => " Opening Sans\n ", + 'slug' => " OPENing SanS \n ", + 'fontFamily' => " Opening Sans\n ", + 'preview' => " https://example.com/ ", + ), + 'expected' => array( + 'name' => 'Opening Sans', + 'slug' => 'opening-sans-alertxss', + 'fontFamily' => '"Opening Sans"', + 'preview' => "https://example.com//stylescriptalert('XSS');/script%20%20%20%20%20%20", + ), + ), + 'multiword font family name with integer' => array( + 'settings' => array( + 'slug' => 'libre-barcode-128-text', + 'fontFamily' => 'Libre Barcode 128 Text', + ), + 'expected' => array( + 'slug' => 'libre-barcode-128-text', + 'fontFamily' => '"Libre Barcode 128 Text"', + ), + ), + 'multiword font family name' => array( + 'settings' => array( + 'slug' => 'b612-mono', + 'fontFamily' => 'B612 Mono', + ), + 'expected' => array( + 'slug' => 'b612-mono', + 'fontFamily' => '"B612 Mono"', + ), + ), + 'comma-separated font family names' => array( + 'settings' => array( + 'slug' => 'open-sans-noto-sans', + 'fontFamily' => 'Open Sans, Noto Sans, sans-serif', + ), + 'expected' => array( + 'slug' => 'open-sans-noto-sans', + 'fontFamily' => '"Open Sans", "Noto Sans", sans-serif', + ), + ), + ); + } + + /** + * @dataProvider data_create_item_invalid_settings + * + * @covers WP_REST_Font_Families_Controller::validate_create_font_face_settings + * + * @param array $settings Settings to test. + */ + public function test_create_item_invalid_settings( $settings ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_create_item_invalid_settings() { + return array( + 'Missing name' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'name' => '' ) ), + ), + 'Empty name' => array( + 'settings' => array_merge( self::$default_settings, array( 'name' => '' ) ), + ), + 'Wrong name type' => array( + 'settings' => array_merge( self::$default_settings, array( 'name' => 1234 ) ), + ), + 'Missing slug' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'slug' => '' ) ), + ), + 'Empty slug' => array( + 'settings' => array_merge( self::$default_settings, array( 'slug' => '' ) ), + ), + 'Wrong slug type' => array( + 'settings' => array_merge( self::$default_settings, array( 'slug' => 1234 ) ), + ), + 'Missing fontFamily' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'fontFamily' => '' ) ), + ), + 'Empty fontFamily' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontFamily' => '' ) ), + ), + 'Wrong fontFamily type' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontFamily' => 1234 ) ), + ), + ); + } + + /** + * @covers WP_REST_Font_Family_Controller::validate_font_family_settings + */ + public function test_create_item_invalid_settings_json() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', 'invalid' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400, 'The response should return an error for "rest_invalid_param" with 400 status.' ); + $expected_message = 'font_family_settings parameter must be a valid JSON string.'; + $message = $response->as_error()->get_all_error_data()[0]['params']['font_family_settings']; + $this->assertSame( $expected_message, $message, 'The response error message should match.' ); + } + + /** + * @covers WP_REST_Font_Family_Controller::create_item + */ + public function test_create_item_with_duplicate_slug() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', wp_json_encode( array_merge( self::$default_settings, array( 'slug' => 'helvetica' ) ) ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_duplicate_font_family', $response, 400, 'The response should return an error for "rest_duplicate_font_family" with 400 status.' ); + $expected_message = 'A font family with slug "helvetica" already exists.'; + $message = $response->as_error()->get_error_messages()[0]; + $this->assertSame( $expected_message, $message, 'The response error message should match.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::create_item + */ + public function test_create_item_no_permission() { + $settings = array_merge( self::$default_settings, array( 'slug' => 'open-sans-2' ) ); + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_create', $response, 401, 'The response should return an error for "rest_cannot_create" with 401 status.' ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( + 'font_family_settings', + wp_json_encode( + array( + 'name' => 'Open Sans', + 'slug' => 'open-sans', + 'fontFamily' => '"Open Sans", sans-serif', + 'preview' => 'https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-normal.svg', + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_create', $response, 403, 'The response should return an error for "rest_cannot_create" with 403 status.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::update_item + */ + public function test_update_item() { + wp_set_current_user( self::$admin_id ); + + $settings = array( + 'name' => 'Open Sans', + 'fontFamily' => '"Open Sans, "Noto Sans", sans-serif', + 'preview' => 'https://s.w.org/images/fonts/16.9/previews/open-sans/open-sans-400-normal.svg', + ); + + $font_family_id = self::create_font_family_post( array( 'slug' => 'open-sans-2' ) ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . $font_family_id ); + $request->set_param( + 'font_family_settings', + wp_json_encode( $settings ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->check_font_family_data( $data, $font_family_id, $response->get_links() ); + + $expected_settings = array( + 'name' => $settings['name'], + 'slug' => 'open-sans-2', + 'fontFamily' => $settings['fontFamily'], + 'preview' => $settings['preview'], + ); + $this->assertSame( $expected_settings, $data['font_family_settings'], 'The response font_family_settings should match expected settings.' ); + } + + /** + * @dataProvider data_update_item_individual_settings + * + * @covers WP_REST_Font_Families_Controller::update_item + * + * @param array $settings Settings to test. + */ + public function test_update_item_individual_settings( $settings ) { + wp_set_current_user( self::$admin_id ); + + $font_family_id = self::create_font_family_post(); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . $font_family_id ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $key = key( $settings ); + $value = current( $settings ); + $this->assertArrayHasKey( $key, $data['font_family_settings'], 'The expected key should exist in the font_family_settings data.' ); + $this->assertSame( $value, $data['font_family_settings'][ $key ], 'The font_family_settings data should match.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_update_item_individual_settings() { + return array( + array( array( 'name' => 'Opened Sans' ) ), + array( array( 'fontFamily' => '"Opened Sans", sans-serif' ) ), + array( array( 'preview' => 'https://s.w.org/images/fonts/16.7/previews/opened-sans/opened-sans-400-normal.svg' ) ), + // Empty preview is allowed. + array( array( 'preview' => '' ) ), + ); + } + + /** + * @dataProvider data_sanitize_font_family_settings + * + * @covers WP_REST_Font_Families_Controller::sanitize_font_family_settings + * + * @param string $settings Font family settings to test. + * @param string $expected Expected settings result. + */ + public function test_update_item_santize_font_family_settings( $settings, $expected ) { + // Unset/modify slug from the data provider, since we're updating rather than creating. + unset( $settings['slug'] ); + $initial_settings = array( 'slug' => 'open-sans-update' ); + $expected = array_merge( self::$default_settings, $expected, $initial_settings ); + + wp_set_current_user( self::$admin_id ); + $font_family_id = self::create_font_family_post( $initial_settings ); + static::$post_ids_to_cleanup[] = $font_family_id; + + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . $font_family_id ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertSame( $expected, $data['font_family_settings'], 'The response font_family_settings should match.' ); + } + + /** + * @dataProvider data_update_item_invalid_settings + * + * @covers WP_REST_Font_Families_Controller::update_item + * + * @param array $settings Settings to test. + */ + public function test_update_item_empty_settings( $settings ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( + 'font_family_settings', + wp_json_encode( $settings ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_update_item_invalid_settings() { + return array( + 'Empty name' => array( + array( 'name' => '' ), + ), + 'Wrong name type' => array( + array( 'name' => 1234 ), + ), + 'Empty fontFamily' => array( + array( 'fontFamily' => '' ), + ), + 'Wrong fontFamily type' => array( + array( 'fontFamily' => 1234 ), + ), + ); + } + + /** + * @covers WP_REST_Font_Families_Controller::update_item + */ + public function test_update_item_update_slug_not_allowed() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( + 'font_family_settings', + wp_json_encode( array( 'slug' => 'new-slug' ) ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400, 'The response should return an error for "rest_invalid_param" with 400 status.' ); + $expected_message = 'font_family_settings[slug] cannot be updated.'; + $message = $response->as_error()->get_all_error_data()[0]['params']['font_family_settings']; + $this->assertSame( $expected_message, $message, 'The response error message should match.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::update_item + */ + public function test_update_item_invalid_font_family_id() { + $settings = array_diff_key( self::$default_settings, array( 'slug' => '' ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404, 'The response should return an error for "rest_post_invalid_id" with 404 status.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::update_item + */ + public function test_update_item_no_permission() { + $settings = array_diff_key( self::$default_settings, array( 'slug' => '' ) ); + + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 401, 'The response should return an error for "rest_cannot_edit" with 401 status for an invalid user.' ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403, 'The response should return an error for "rest_cannot_edit" with 403 status for a user without permission.' ); + } + + + /** + * @covers WP_REST_Font_Families_Controller::delete_item + */ + public function test_delete_item() { + wp_set_current_user( self::$admin_id ); + $font_family_id = self::create_font_family_post(); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . $font_family_id ); + $request['force'] = true; + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertNull( get_post( $font_family_id ), 'The post should not exist after deleting.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::delete_item + */ + public function test_delete_item_no_trash() { + wp_set_current_user( self::$admin_id ); + $font_family_id = self::create_font_family_post(); + + // Attempt trashing. + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501, 'The response should return an error for "rest_trash_not_supported" with 501 status.' ); + + $request->set_param( 'force', 'false' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501, 'When "force" is false, the response should return an error for "rest_trash_not_supported" with 501 status.' ); + + // Ensure the post still exists. + $post = get_post( $font_family_id ); + $this->assertNotEmpty( $post, 'The post should still exist.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::delete_item + */ + public function test_delete_item_invalid_font_family_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Families_Controller::delete_item + */ + public function test_delete_item_no_permissions() { + $font_family_id = self::create_font_family_post(); + + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, 401, 'The response should return an error for "rest_cannot_delete" with 401 status for an invalid user.' ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403, 'The response should return an error for "rest_cannot_delete" with 403 status for a user without permission.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::prepare_item_for_response + */ + public function test_prepare_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id2 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->check_font_family_data( $data, self::$font_family_id2, $response->get_links() ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_item_schema + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/font-families' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $properties = $data['schema']['properties']; + $this->assertCount( 4, $properties, 'There should be 4 properties in the schema::properties data.' ); + $this->assertArrayHasKey( 'id', $properties, 'The id property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'theme_json_version', $properties, 'The theme_json_version property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'font_faces', $properties, 'The font_faces property should exist in the schema::properties data.' ); + $this->assertArrayHasKey( 'font_family_settings', $properties, 'The font_family_settings property should exist in the schema::properties data.' ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_item_schema + */ + public function test_get_item_schema_font_family_settings_should_all_have_sanitize_callbacks() { + $schema = ( new WP_REST_Font_Families_Controller( 'wp_font_family' ) )->get_item_schema(); + $font_family_settings_schema = $schema['properties']['font_family_settings']; + + $this->assertArrayHasKey( 'properties', $font_family_settings_schema, 'font_family_settings schema is missing properties.' ); + $this->assertIsArray( $font_family_settings_schema['properties'], 'font_family_settings properties should be an array.' ); + + // arg_options should be removed for each setting property. + foreach ( $font_family_settings_schema['properties'] as $property ) { + $this->assertArrayHasKey( 'arg_options', $property, 'Setting schema should have arg_options.' ); + $this->assertArrayHasKey( 'sanitize_callback', $property['arg_options'], 'Setting schema should have a sanitize_callback.' ); + $this->assertIsCallable( $property['arg_options']['sanitize_callback'], 'That sanitize_callback value should be callable.' ); + } + } + + /** + * @covers WP_REST_Font_Families_Controller::get_public_item_schema + */ + public function test_get_public_item_schema_should_not_have_arg_options() { + $schema = ( new WP_REST_Font_Families_Controller( 'wp_font_family' ) )->get_public_item_schema(); + $font_family_settings_schema = $schema['properties']['font_family_settings']; + + $this->assertArrayHasKey( 'properties', $font_family_settings_schema, 'font_family_settings schema is missing properties.' ); + $this->assertIsArray( $font_family_settings_schema['properties'], 'font_family_settings properties should be an array.' ); + + // arg_options should be removed for each setting property. + foreach ( $font_family_settings_schema['properties'] as $property ) { + $this->assertArrayNotHasKey( 'arg_options', $property, 'arg_options should be removed from the schema for each setting.' ); + } + } + + protected function check_font_family_data( $data, $post_id, $links ) { + static::$post_ids_to_cleanup[] = $post_id; + $post = get_post( $post_id ); + + $this->assertArrayHasKey( 'id', $data, 'The id property should exist in response data.' ); + $this->assertSame( $post->ID, $data['id'], 'The "id" from the response data should match the post ID.' ); + + $this->assertArrayHasKey( 'theme_json_version', $data, 'The theme_json_version property should exist in response data.' ); + $this->assertSame( WP_Theme_JSON::LATEST_SCHEMA, $data['theme_json_version'], 'The "theme_json_version" from the response data should match WP_Theme_JSON::LATEST_SCHEMA.' ); + + $font_face_ids = get_children( + array( + 'fields' => 'ids', + 'post_parent' => $post_id, + 'post_type' => 'wp_font_face', + 'order' => 'ASC', + 'orderby' => 'ID', + ) + ); + $this->assertArrayHasKey( 'font_faces', $data, 'The font_faces property should exist in the response data.' ); + + foreach ( $font_face_ids as $font_face_id ) { + $this->assertContains( $font_face_id, $data['font_faces'], 'The ID is in the font_faces data.' ); + } + + $this->assertArrayHasKey( 'font_family_settings', $data, 'The font_family_settings property should exist in the response data.' ); + $settings = $data['font_family_settings']; + $expected_settings = array( + 'name' => $post->post_title, + 'slug' => $post->post_name, + 'fontFamily' => $settings['fontFamily'], + 'preview' => $settings['preview'], + ); + $this->assertSame( $expected_settings, $settings, 'The font_family_settings should match.' ); + + $this->assertNotEmpty( $links, 'The links should not be empty in the response data.' ); + $expected = rest_url( 'wp/v2/font-families/' . $post->ID ); + $this->assertSame( $expected, $links['self'][0]['href'], 'The links URL from the response data should match the post\'s REST endpoint.' ); + $expected = rest_url( 'wp/v2/font-families' ); + $this->assertSame( $expected, $links['collection'][0]['href'], 'The links collection URL from the response data should match the REST endpoint.' ); + + if ( ! $font_face_ids ) { + return; + } + + // Check font_face links, if present. + $this->assertArrayHasKey( 'font_faces', $links ); + foreach ( $links['font_faces'] as $index => $link ) { + $expected = rest_url( 'wp/v2/font-families/' . $post->ID . '/font-faces/' . $font_face_ids[ $index ] ); + $this->assertSame( $expected, $link['href'], 'The links for a font faces URL from the response data should match the REST endpoint.' ); + + $embeddable = isset( $link['attributes']['embeddable'] ) + ? $link['attributes']['embeddable'] + : $link['embeddable']; + $this->assertTrue( $embeddable, 'The embeddable should be true.' ); + } + } +}