diff --git a/includes/abilities/class-scf-abilities-integration.php b/includes/abilities/class-scf-abilities-integration.php index 0d87f8d3..5cca3188 100644 --- a/includes/abilities/class-scf-abilities-integration.php +++ b/includes/abilities/class-scf-abilities-integration.php @@ -44,6 +44,7 @@ public function init() { acf_include( 'includes/abilities/class-scf-taxonomy-abilities.php' ); acf_include( 'includes/abilities/class-scf-ui-options-page-abilities.php' ); acf_include( 'includes/abilities/class-scf-field-group-abilities.php' ); + acf_include( 'includes/abilities/class-scf-field-abilities.php' ); } /** diff --git a/includes/abilities/class-scf-field-abilities.php b/includes/abilities/class-scf-field-abilities.php new file mode 100644 index 00000000..29e6c991 --- /dev/null +++ b/includes/abilities/class-scf-field-abilities.php @@ -0,0 +1,801 @@ +validate_required_schemas() ) { + return; + } + + add_action( 'wp_abilities_api_categories_init', array( $this, 'register_categories' ) ); + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + } + + /** + * Gets the field manager instance. + * + * @since 6.8.0 + * + * @return SCF_Field_Manager + */ + private function manager() { + if ( null === $this->manager ) { + $this->manager = new SCF_Field_Manager(); + } + return $this->manager; + } + + /** + * Gets the ability category name. + * + * @since 6.8.0 + * + * @return string + */ + private function ability_category() { + return 'scf-fields'; + } + + /** + * Generates an ability name. + * + * @since 6.8.0 + * + * @param string $action The action (list, get, create, etc.). + * @return string E.g., 'scf/list-fields', 'scf/get-field'. + */ + private function ability_name( $action ) { + $suffix = 'list' === $action ? 'fields' : 'field'; + return 'scf/' . $action . '-' . $suffix; + } + + /** + * Gets the entity schema. + * + * @since 6.8.0 + * + * @return array + */ + private function get_field_schema() { + if ( null === $this->field_schema ) { + $schema_path = ACF_PATH . 'schemas/field.schema.json'; + $schema_content = file_get_contents( $schema_path ); + $this->field_schema = json_decode( $schema_content, true ); + + // Resolve $ref references. + $this->field_schema = $this->resolve_schema_refs( $this->field_schema ); + } + return $this->field_schema; + } + + /** + * Recursively resolves $ref references in a JSON schema. + * + * @since 6.8.0 + * + * @param array $schema The schema to resolve. + * @return array The resolved schema. + */ + private function resolve_schema_refs( array $schema ) { + // If this object has a $ref, resolve it. + if ( isset( $schema['$ref'] ) ) { + $ref = $schema['$ref']; + if ( strpos( $ref, '#/definitions/' ) === 0 ) { + $def_name = substr( $ref, 14 ); + $full = $this->get_field_schema(); + $definition = isset( $full['definitions'][ $def_name ] ) ? $full['definitions'][ $def_name ] : array(); + if ( empty( $definition ) ) { + _doing_it_wrong( __METHOD__, 'Unresolvable $ref: ' . esc_html( $ref ), '6.8.0' ); + return $schema; + } + // Merge resolved definition with any additional properties. + unset( $schema['$ref'] ); + $schema = array_merge( $definition, $schema ); + } + } + + // Recurse into nested arrays/objects. + foreach ( $schema as $key => $value ) { + if ( is_array( $value ) ) { + $schema[ $key ] = $this->resolve_schema_refs( $value ); + } + } + + return $schema; + } + + /** + * Gets the SCF identifier schema. + * + * @since 6.8.0 + * + * @return array + */ + private function get_scf_identifier_schema() { + if ( null === $this->scf_identifier_schema ) { + $schema_path = ACF_PATH . 'schemas/scf-identifier.schema.json'; + $schema_content = file_get_contents( $schema_path ); + $this->scf_identifier_schema = json_decode( $schema_content, true ); + } + return $this->scf_identifier_schema; + } + + /** + * Gets the internal fields schema for fields. + * + * @since 6.8.0 + * + * @return array + */ + private function get_internal_fields_schema() { + $validator = new SCF_JSON_Schema_Validator(); + $schema = $validator->load_schema( 'internal-fields' ); + return json_decode( wp_json_encode( $schema->definitions->fieldInternalFields ), true ); + } + + /** + * Gets the field schema merged with internal fields. + * + * Used for output schemas of GET/LIST/CREATE/UPDATE/DUPLICATE abilities. + * Export uses get_field_schema() directly (no internal fields). + * + * The field schema uses oneOf at the top level with the actual field definition + * in definitions.field. Internal fields are merged into that definition. + * + * @since 6.8.0 + * + * @return array + */ + private function get_field_with_internal_fields_schema() { + $schema = $this->get_field_schema(); + $internal = $this->get_internal_fields_schema(); + + // Merge internal fields into the field definition's properties. + $schema['definitions']['field']['properties'] = array_merge( + $schema['definitions']['field']['properties'], + $internal['properties'] + ); + + return $schema; + } + + /** + * Registers ability categories. + * + * @since 6.8.0 + */ + public function register_categories() { + wp_register_ability_category( + $this->ability_category(), + array( + 'label' => __( 'SCF Fields', 'secure-custom-fields' ), + 'description' => __( 'Abilities for managing Secure Custom Fields fields.', 'secure-custom-fields' ), + ) + ); + } + + /** + * Registers all field abilities. + * + * @since 6.8.0 + */ + public function register_abilities() { + $this->register_list_ability(); + $this->register_get_ability(); + $this->register_create_ability(); + $this->register_update_ability(); + $this->register_delete_ability(); + $this->register_duplicate_ability(); + $this->register_export_ability(); + $this->register_import_ability(); + } + + /** + * Registers the list ability. + * + * @since 6.8.0 + */ + private function register_list_ability() { + wp_register_ability( + $this->ability_name( 'list' ), + array( + 'label' => __( 'List Fields', 'secure-custom-fields' ), + 'description' => __( 'Retrieves a list of SCF fields. Returns all if no filter provided. Can filter by parent (field group), type, or name.', 'secure-custom-fields' ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'list_callback' ), + 'permission_callback' => 'scf_current_user_has_capability', + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'filter' => array( + 'type' => 'object', + 'description' => __( 'Filter fields by parent, type, or name.', 'secure-custom-fields' ), + 'properties' => array( + 'parent' => array( + 'type' => array( 'integer', 'string' ), + 'description' => __( 'Field group ID or key to filter by.', 'secure-custom-fields' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'Field type to filter by (e.g., text, image).', 'secure-custom-fields' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'Field name to filter by.', 'secure-custom-fields' ), + ), + ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'array', + 'items' => $this->get_field_with_internal_fields_schema(), + ), + ) + ); + } + + /** + * Registers the get ability. + * + * @since 6.8.0 + */ + private function register_get_ability() { + wp_register_ability( + $this->ability_name( 'get' ), + array( + 'label' => __( 'Get Field', 'secure-custom-fields' ), + 'description' => __( 'Retrieves a single SCF field by key or ID.', 'secure-custom-fields' ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'get_callback' ), + 'permission_callback' => 'scf_current_user_has_capability', + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + ), + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'identifier' ), + 'properties' => array( + 'identifier' => $this->get_scf_identifier_schema(), + ), + ), + 'output_schema' => $this->get_field_with_internal_fields_schema(), + ) + ); + } + + /** + * Registers the create ability. + * + * @since 6.8.0 + */ + private function register_create_ability() { + wp_register_ability( + $this->ability_name( 'create' ), + array( + 'label' => __( 'Create Field', 'secure-custom-fields' ), + 'description' => __( 'Creates a new SCF field with provided configuration. Requires parent (field group ID).', 'secure-custom-fields' ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'create_callback' ), + 'permission_callback' => 'scf_current_user_has_capability', + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + ), + 'input_schema' => $this->get_field_schema(), + 'output_schema' => $this->get_field_with_internal_fields_schema(), + ) + ); + } + + /** + * Registers the update ability. + * + * @since 6.8.0 + */ + private function register_update_ability() { + // Get field properties from field schema. + $field_schema = $this->get_field_schema(); + $field_properties = array(); + if ( isset( $field_schema['definitions']['field']['properties'] ) ) { + $field_properties = $field_schema['definitions']['field']['properties']; + } + + wp_register_ability( + $this->ability_name( 'update' ), + array( + 'label' => __( 'Update Field', 'secure-custom-fields' ), + 'description' => __( 'Updates an existing SCF field. Properties not provided are preserved (merge behavior).', 'secure-custom-fields' ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'update_callback' ), + 'permission_callback' => 'scf_current_user_has_capability', + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ), + ), + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'ID' ), + 'properties' => array_merge( + array( + 'ID' => array( + 'type' => 'integer', + 'description' => __( 'The field ID.', 'secure-custom-fields' ), + ), + ), + $field_properties + ), + ), + 'output_schema' => $this->get_field_with_internal_fields_schema(), + ) + ); + } + + /** + * Registers the delete ability. + * + * @since 6.8.0 + */ + private function register_delete_ability() { + wp_register_ability( + $this->ability_name( 'delete' ), + array( + 'label' => __( 'Delete Field', 'secure-custom-fields' ), + 'description' => __( 'Permanently deletes an SCF field.', 'secure-custom-fields' ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'delete_callback' ), + 'permission_callback' => 'scf_current_user_has_capability', + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => false, + 'destructive' => true, + 'idempotent' => true, + ), + ), + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'identifier' ), + 'properties' => array( + 'identifier' => $this->get_scf_identifier_schema(), + ), + ), + 'output_schema' => array( + 'type' => 'boolean', + ), + ) + ); + } + + /** + * Registers the duplicate ability. + * + * @since 6.8.0 + */ + private function register_duplicate_ability() { + wp_register_ability( + $this->ability_name( 'duplicate' ), + array( + 'label' => __( 'Duplicate Field', 'secure-custom-fields' ), + 'description' => __( 'Creates a copy of an SCF field with a new unique key. Optionally specify a new parent field group.', 'secure-custom-fields' ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'duplicate_callback' ), + 'permission_callback' => 'scf_current_user_has_capability', + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + ), + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'identifier' ), + 'properties' => array( + 'identifier' => $this->get_scf_identifier_schema(), + 'new_parent_id' => array( + 'type' => 'integer', + 'description' => __( 'Optional field group ID to place the duplicate in.', 'secure-custom-fields' ), + ), + ), + ), + 'output_schema' => $this->get_field_with_internal_fields_schema(), + ) + ); + } + + /** + * Registers the export ability. + * + * @since 6.8.0 + */ + private function register_export_ability() { + wp_register_ability( + $this->ability_name( 'export' ), + array( + 'label' => __( 'Export Field', 'secure-custom-fields' ), + 'description' => __( 'Exports an SCF field as JSON for backup or transfer. Internal fields (ID, local, _valid) are stripped.', 'secure-custom-fields' ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'export_callback' ), + 'permission_callback' => 'scf_current_user_has_capability', + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + ), + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'identifier' ), + 'properties' => array( + 'identifier' => $this->get_scf_identifier_schema(), + ), + ), + 'output_schema' => $this->get_field_schema(), + ) + ); + } + + /** + * Registers the import ability. + * + * @since 6.8.0 + */ + private function register_import_ability() { + wp_register_ability( + $this->ability_name( 'import' ), + array( + 'label' => __( 'Import Field', 'secure-custom-fields' ), + 'description' => __( 'Imports an SCF field from JSON data. If key exists, updates existing; otherwise creates new.', 'secure-custom-fields' ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'import_callback' ), + 'permission_callback' => 'scf_current_user_has_capability', + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ), + ), + 'input_schema' => $this->get_field_schema(), + 'output_schema' => $this->get_field_with_internal_fields_schema(), + ) + ); + } + + /** + * Handles the list ability callback. + * + * @since 6.8.0 + * + * @param array $input The input parameters. + * @return array List of fields. + */ + public function list_callback( $input ) { + $filter = isset( $input['filter'] ) && is_array( $input['filter'] ) ? $input['filter'] : array(); + return $this->manager()->filter_posts( $this->manager()->get_posts(), $filter ); + } + + /** + * Handles the get ability callback. + * + * @since 6.8.0 + * + * @param array $input The input parameters. + * @return array|WP_Error Field data or error if not found. + */ + public function get_callback( $input ) { + $field = $this->manager()->get_post( $input['identifier'] ); + if ( ! $field ) { + return $this->not_found_error(); + } + return $field; + } + + /** + * Handles the create ability callback. + * + * @since 6.8.0 + * + * @param array $input The field data to create. + * @return array|WP_Error Created field or error on failure. + */ + public function create_callback( $input ) { + // Check for existing field with same key. + if ( isset( $input['key'] ) && $this->manager()->get_post( $input['key'] ) ) { + return new WP_Error( + 'already_exists', + __( 'Field with this key already exists.', 'secure-custom-fields' ) + ); + } + + if ( ! $this->parent_exists( $input['parent'] ) ) { + return new WP_Error( + 'parent_not_found', + __( 'Parent field group or field does not exist.', 'secure-custom-fields' ), + array( 'status' => 400 ) + ); + } + + $field = $this->manager()->update_post( $input ); + if ( ! $field ) { + return new WP_Error( + 'create_failed', + __( 'Failed to create field.', 'secure-custom-fields' ) + ); + } + return $field; + } + + /** + * Handles the update ability callback. + * + * @since 6.8.0 + * + * @param array $input The field data to update. + * @return array|WP_Error Updated field or error on failure. + */ + public function update_callback( $input ) { + $existing = $this->manager()->get_post( $input['ID'] ); + if ( ! $existing ) { + return $this->not_found_error(); + } + + // Merge with existing data. + $updated_data = array_merge( $existing, $input ); + $field = $this->manager()->update_post( $updated_data ); + + if ( ! $field ) { + return new WP_Error( + 'update_failed', + __( 'Failed to update field.', 'secure-custom-fields' ) + ); + } + return $field; + } + + /** + * Handles the delete ability callback. + * + * @since 6.8.0 + * + * @param array $input The input parameters. + * @return bool|WP_Error True on success or error on failure. + */ + public function delete_callback( $input ) { + if ( ! $this->manager()->get_post( $input['identifier'] ) ) { + return $this->not_found_error(); + } + + if ( ! $this->manager()->delete_post( $input['identifier'] ) ) { + return new WP_Error( + 'delete_failed', + __( 'Failed to delete field.', 'secure-custom-fields' ) + ); + } + return true; + } + + /** + * Handles the duplicate ability callback. + * + * @since 6.8.0 + * + * @param array $input The input parameters. + * @return array|WP_Error Duplicated field or error on failure. + */ + public function duplicate_callback( $input ) { + if ( ! $this->manager()->get_post( $input['identifier'] ) ) { + return $this->not_found_error(); + } + + $new_parent_id = isset( $input['new_parent_id'] ) ? $input['new_parent_id'] : 0; + + // Validate that new_parent_id references an existing parent (field group or parent field). + if ( $new_parent_id && ! $this->parent_exists( $new_parent_id ) ) { + return new WP_Error( + 'invalid_new_parent_id', + sprintf( + /* translators: %d: Invalid parent ID */ + __( 'Invalid new_parent_id: %d is not a valid field group or parent field.', 'secure-custom-fields' ), + $new_parent_id + ), + array( 'status' => 400 ) + ); + } + + $duplicated = $this->manager()->duplicate_post( $input['identifier'], $new_parent_id ); + + if ( ! $duplicated ) { + return new WP_Error( + 'duplicate_failed', + __( 'Failed to duplicate field.', 'secure-custom-fields' ) + ); + } + return $duplicated; + } + + /** + * Handles the export ability callback. + * + * @since 6.8.0 + * + * @param array $input The input parameters. + * @return array|WP_Error Exported field data or error on failure. + */ + public function export_callback( $input ) { + $field = $this->manager()->get_post( $input['identifier'] ); + if ( ! $field ) { + return $this->not_found_error(); + } + + $export = $this->manager()->prepare_post_for_export( $field ); + if ( ! $export ) { + return new WP_Error( + 'export_failed', + __( 'Failed to export field.', 'secure-custom-fields' ) + ); + } + return $export; + } + + /** + * Handles the import ability callback. + * + * @since 6.8.0 + * + * @param array|object $input The field data to import. + * @return array|WP_Error Imported field or error on failure. + */ + public function import_callback( $input ) { + if ( ! $this->parent_exists( $input['parent'] ) ) { + return new WP_Error( + 'parent_not_found', + __( 'Parent field group or field does not exist.', 'secure-custom-fields' ), + array( 'status' => 400 ) + ); + } + + $imported = $this->manager()->import_post( $input ); + if ( ! $imported ) { + return new WP_Error( + 'import_failed', + __( 'Failed to import field.', 'secure-custom-fields' ) + ); + } + return $imported; + } + + /** + * Checks if the parent field group or field exists. + * + * @since 6.8.0 + * + * @param int|string $parent_id The parent ID or key. + * @return bool True if parent exists, false otherwise. + */ + private function parent_exists( $parent_id ) { + /** + * Filters the result of the parent existence check. + * + * @since 6.8.0 + * + * @param bool|null $exists The existence result. Null to use default logic. + * @param int|string $parent_id The parent ID or key being checked. + */ + $filtered = apply_filters( 'scf_field_parent_exists', null, $parent_id ); + if ( null !== $filtered ) { + return (bool) $filtered; + } + + // Parent can be a field group or a parent field (for sub-fields). + return (bool) acf_get_field_group( $parent_id ) || (bool) acf_get_field( $parent_id ); + } + + /** + * Creates a not found error response. + * + * @since 6.8.0 + * + * @return WP_Error + */ + private function not_found_error() { + return new WP_Error( + 'not_found', + __( 'Field not found.', 'secure-custom-fields' ), + array( 'status' => 404 ) + ); + } + } + + // Initialize abilities instance. + acf_new_instance( 'SCF_Field_Abilities' ); + +endif; // class_exists check. diff --git a/includes/class-scf-json-schema-validator.php b/includes/class-scf-json-schema-validator.php index 6bdd0ed4..74ea2169 100644 --- a/includes/class-scf-json-schema-validator.php +++ b/includes/class-scf-json-schema-validator.php @@ -102,6 +102,13 @@ public function validate_data( $data, $schema_name ) { $common_schema_content = wp_json_file_decode( $common_schema_path ); $schema_storage->addSchema( 'file://common.schema.json', $common_schema_content ); + // Register field schema (needed for field-group schema $ref) + $field_schema_path = $this->schema_path . 'field.schema.json'; + $field_schema_content = wp_json_file_decode( $field_schema_path ); + if ( $field_schema_content ) { + $schema_storage->addSchema( 'file://field.schema.json', $field_schema_content ); + } + // Register main schema $main_schema_uri = 'file://' . $schema_name . '.schema.json'; $schema_storage->addSchema( $main_schema_uri, $schema ); diff --git a/includes/post-types/class-scf-field-manager.php b/includes/post-types/class-scf-field-manager.php index 2c4d3715..5342a930 100644 --- a/includes/post-types/class-scf-field-manager.php +++ b/includes/post-types/class-scf-field-manager.php @@ -89,10 +89,18 @@ public function get_posts() { public function filter_posts( $posts, $args = array() ) { if ( isset( $args['parent'] ) ) { $parent_filter = $args['parent']; + + // Convert key to ID if not numeric (same pattern as acf_update_field). + if ( $parent_filter && ! is_numeric( $parent_filter ) ) { + $parent_post = acf_get_field_post( $parent_filter ); + $parent_filter = $parent_post ? $parent_post->ID : 0; + } + + $parent_filter = (int) $parent_filter; $posts = array_filter( $posts, function ( $post ) use ( $parent_filter ) { - return $post['parent'] === $parent_filter; + return (int) $post['parent'] === $parent_filter; } ); } @@ -144,30 +152,6 @@ public function delete_post( $id = 0 ) { return acf_delete_field( $id ); } - /** - * Moves a field to trash. - * - * @since 6.8.0 - * - * @param int|string $id The field ID or key. - * @return bool True on success, false on failure. - */ - public function trash_post( $id = 0 ) { - return acf_trash_field( $id ); - } - - /** - * Restores a field from trash. - * - * @since 6.8.0 - * - * @param int|string $id The field ID or key. - * @return bool True on success, false on failure. - */ - public function untrash_post( $id = 0 ) { - return acf_untrash_field( $id ); - } - /** * Duplicates a field. * diff --git a/schemas/field-group.schema.json b/schemas/field-group.schema.json index b72f9a7f..b17b3c5d 100644 --- a/schemas/field-group.schema.json +++ b/schemas/field-group.schema.json @@ -30,7 +30,7 @@ "title": { "$ref": "file://common.schema.json#/definitions/title" }, "fields": { "type": "array", - "items": { "$ref": "#/definitions/field" }, + "items": { "$ref": "file://field.schema.json#/definitions/field" }, "description": "Array of field definitions. If empty array, field group appears but contains no fields." }, "menu_order": { "$ref": "file://common.schema.json#/definitions/menu_order" }, @@ -105,131 +105,6 @@ } } }, - "field": { - "type": "object", - "required": [ "key", "label", "name", "type" ], - "additionalProperties": true, - "properties": { - "key": { - "type": "string", - "pattern": "^field_.+$", - "minLength": 1, - "description": "Unique identifier for the field with field_ prefix (e.g. 'field_abc123')" - }, - "label": { - "type": "string", - "minLength": 1, - "description": "The label displayed for this field" - }, - "name": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]*$", - "description": "The field name/meta_key used to save and load data (empty for layout fields like tab, accordion)" - }, - "aria-label": { - "type": "string", - "description": "Accessible label for screen readers" - }, - "type": { - "type": "string", - "enum": [ - "text", - "textarea", - "number", - "range", - "email", - "url", - "password", - "image", - "file", - "wysiwyg", - "oembed", - "gallery", - "select", - "checkbox", - "radio", - "button_group", - "true_false", - "link", - "post_object", - "page_link", - "relationship", - "taxonomy", - "user", - "google_map", - "date_picker", - "date_time_picker", - "time_picker", - "color_picker", - "icon_picker", - "message", - "accordion", - "tab", - "group", - "repeater", - "flexible_content", - "clone", - "nav_menu", - "separator", - "output" - ], - "description": "The type of field" - }, - "instructions": { - "type": "string", - "description": "Instructions for content editors" - }, - "required": { - "type": [ "boolean", "integer" ], - "default": false, - "description": "Whether the field is required" - }, - "conditional_logic": { - "oneOf": [ - { "type": "boolean", "enum": [ false ] }, - { "type": "integer", "enum": [ 0 ] }, - { - "type": "array", - "items": { - "$ref": "#/definitions/conditionalLogicGroup" - } - } - ], - "description": "Conditional logic rules for field visibility" - }, - "wrapper": { - "type": "object", - "additionalProperties": false, - "properties": { - "width": { - "type": "string", - "description": "Width of the field wrapper (e.g. '50' for 50%)" - }, - "class": { - "type": "string", - "description": "CSS class(es) for the field wrapper" - }, - "id": { - "type": "string", - "description": "HTML ID for the field wrapper" - } - }, - "description": "Wrapper element settings" - }, - "menu_order": { - "type": "integer", - "description": "Order of the field within its parent" - }, - "parent": { - "oneOf": [ { "type": "string" }, { "type": "integer" } ], - "description": "Parent field or field group key/ID" - }, - "parent_layout": { - "type": "string", - "description": "Parent layout key for flexible content sub-fields" - } - } - }, "locationGroup": { "type": "array", "items": { "$ref": "#/definitions/locationRule" }, @@ -256,44 +131,6 @@ "description": "Value to compare against" } } - }, - "conditionalLogicGroup": { - "type": "array", - "items": { "$ref": "#/definitions/conditionalLogicRule" }, - "minItems": 1, - "description": "Group of conditional logic rules (AND logic within group)" - }, - "conditionalLogicRule": { - "type": "object", - "required": [ "field", "operator" ], - "additionalProperties": false, - "properties": { - "field": { - "type": "string", - "pattern": "^field_.+$", - "description": "Field key to check" - }, - "operator": { - "type": "string", - "enum": [ - "==", - "!=", - "!==", - "==empty", - "!=empty", - "==pattern", - "==contains", - "!=contains", - "<", - ">" - ], - "description": "Comparison operator" - }, - "value": { - "type": "string", - "description": "Value to compare against (not required for ==empty and !=empty operators)" - } - } } } } diff --git a/schemas/field.schema.json b/schemas/field.schema.json new file mode 100644 index 00000000..0c322b81 --- /dev/null +++ b/schemas/field.schema.json @@ -0,0 +1,189 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/WordPress/secure-custom-fields/trunk/schemas/field.schema.json", + "title": "SCF Field", + "description": "Schema for Secure Custom Fields field definitions. Validates field structure and base properties. Type-specific field properties are allowed but not strictly validated.", + "$comment": "WordPress doesn't support 'allOf', so we use '$ref' with explicit 'required' arrays. Since PHP's array_merge() replaces (not merges) nested arrays, we must redeclare all required fields from the definition plus 'parent'.", + "oneOf": [ + { + "description": "Single field object with required parent", + "$ref": "#/definitions/field", + "required": [ "key", "label", "name", "type", "parent" ] + }, + { + "description": "Array of field objects with required parent", + "type": "array", + "items": { + "$ref": "#/definitions/field", + "required": [ "key", "label", "name", "type", "parent" ] + }, + "minItems": 1 + } + ], + "definitions": { + "field": { + "type": "object", + "required": [ "key", "label", "name", "type" ], + "additionalProperties": true, + "properties": { + "key": { + "type": "string", + "pattern": "^field_.+$", + "minLength": 1, + "description": "Unique identifier for the field with field_ prefix (e.g. 'field_abc123')" + }, + "label": { + "type": "string", + "minLength": 1, + "description": "The label displayed for this field" + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]*$", + "description": "The field name/meta_key used to save and load data (empty for layout fields like tab, accordion)" + }, + "aria-label": { + "type": "string", + "description": "Accessible label for screen readers" + }, + "type": { + "type": "string", + "enum": [ + "text", + "textarea", + "number", + "range", + "email", + "url", + "password", + "image", + "file", + "wysiwyg", + "oembed", + "gallery", + "select", + "checkbox", + "radio", + "button_group", + "true_false", + "link", + "post_object", + "page_link", + "relationship", + "taxonomy", + "user", + "google_map", + "date_picker", + "date_time_picker", + "time_picker", + "color_picker", + "icon_picker", + "message", + "accordion", + "tab", + "group", + "repeater", + "flexible_content", + "clone", + "nav_menu", + "separator", + "output" + ], + "description": "The type of field" + }, + "instructions": { + "type": "string", + "description": "Instructions for content editors" + }, + "required": { + "type": [ "boolean", "integer" ], + "default": false, + "description": "Whether the field is required" + }, + "conditional_logic": { + "anyOf": [ + { "type": "boolean", "enum": [ false ] }, + { "type": "integer", "enum": [ 0 ] }, + { "type": "string", "enum": [ "" ] }, + { + "type": "array", + "items": { + "$ref": "#/definitions/conditionalLogicGroup" + } + } + ], + "description": "Conditional logic rules for field visibility" + }, + "wrapper": { + "type": "object", + "additionalProperties": false, + "properties": { + "width": { + "type": "string", + "description": "Width of the field wrapper (e.g. '50' for 50%)" + }, + "class": { + "type": "string", + "description": "CSS class(es) for the field wrapper" + }, + "id": { + "type": "string", + "description": "HTML ID for the field wrapper" + } + }, + "description": "Wrapper element settings" + }, + "menu_order": { + "type": "integer", + "description": "Order of the field within its parent" + }, + "parent": { + "oneOf": [ { "type": "string" }, { "type": "integer" } ], + "description": "Parent field or field group key/ID" + }, + "parent_layout": { + "type": "string", + "description": "Parent layout key for flexible content sub-fields" + } + } + }, + "conditionalLogicGroup": { + "type": "array", + "items": { "$ref": "#/definitions/conditionalLogicRule" }, + "minItems": 1, + "description": "Group of conditional logic rules (AND logic within group)" + }, + "conditionalLogicRule": { + "type": "object", + "required": [ "field", "operator" ], + "additionalProperties": false, + "properties": { + "field": { + "type": "string", + "pattern": "^field_.+$", + "description": "Field key to check" + }, + "operator": { + "type": "string", + "enum": [ + "==", + "!=", + "!==", + "==empty", + "!=empty", + "==pattern", + "==contains", + "!=contains", + "<", + ">" + ], + "description": "Comparison operator" + }, + "value": { + "type": "string", + "description": "Value to compare against (not required for ==empty and !=empty operators)" + } + } + } + } +} diff --git a/schemas/internal-fields.schema.json b/schemas/internal-fields.schema.json index 186e20ab..0f96f76a 100644 --- a/schemas/internal-fields.schema.json +++ b/schemas/internal-fields.schema.json @@ -2,32 +2,43 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://raw.githubusercontent.com/WordPress/secure-custom-fields/trunk/schemas/internal-fields.schema.json", "title": "SCF Internal Fields", - "description": "Internal fields present in SCF entities (post types, taxonomies, field groups, options pages). These fields are managed by the system and appear in GET/LIST/CREATE/UPDATE/IMPORT/DUPLICATE outputs, but are stripped during EXPORT.", + "description": "Internal fields present in SCF entities. These fields are managed by the system and appear in GET/LIST/CREATE/UPDATE/IMPORT/DUPLICATE outputs, but are stripped during EXPORT.", "definitions": { + "ID": { + "type": "integer", + "minimum": 1, + "description": "WordPress post ID. Present in GET/LIST/CREATE/UPDATE/IMPORT/DUPLICATE outputs." + }, + "_valid": { + "type": [ "boolean", "integer" ], + "description": "Validation cache flag. Indicates whether the entity passed validation. Can be boolean or integer (0/1)." + }, + "local": { + "type": "string", + "enum": [ "json" ], + "description": "Source indicator. Present only if entity is locally-defined (e.g., from local JSON files). Stripped during export." + }, + "not_registered": { + "type": "boolean", + "description": "Flag indicating that this entity could not be registered because another one with the same key already exists. Present only when registration was skipped." + }, + "fieldInternalFields": { + "type": "object", + "description": "Internal fields for field entities (ID and _valid only)", + "properties": { + "ID": { "$ref": "#/definitions/ID" }, + "_valid": { "$ref": "#/definitions/_valid" } + } + }, "internalFields": { "type": "object", - "description": "Internal system-managed fields that appear in entity responses", + "description": "Internal fields for internal post types (field groups, taxonomies, options pages)", "properties": { - "ID": { - "type": "integer", - "minimum": 1, - "description": "WordPress post ID. Present in GET/LIST/CREATE/UPDATE/IMPORT/DUPLICATE outputs. Optional in IMPORT input - if provided, updates existing entity; if omitted, creates new entity." - }, - "_valid": { - "type": "boolean", - "description": "Validation cache flag. Indicates whether the entity passed validation." - }, - "local": { - "type": "string", - "enum": ["json"], - "description": "Source indicator. Present only if entity is locally-defined (e.g., from local JSON files). Stripped during export." - }, - "not_registered": { - "type": "boolean", - "description": "Flag indicating that this entity could not be registered because another one with the same key already exists. Present only when registration was skipped." - } - }, - "additionalProperties": false + "ID": { "$ref": "#/definitions/ID" }, + "_valid": { "$ref": "#/definitions/_valid" }, + "local": { "$ref": "#/definitions/local" }, + "not_registered": { "$ref": "#/definitions/not_registered" } + } } } } diff --git a/tests/e2e/abilities-fields.spec.ts b/tests/e2e/abilities-fields.spec.ts new file mode 100644 index 00000000..3d83e211 --- /dev/null +++ b/tests/e2e/abilities-fields.spec.ts @@ -0,0 +1,512 @@ +/** + * E2E tests for SCF Field Abilities + * + * Tests the WordPress Abilities API endpoints for SCF field management. + * Fields require a parent field group, so we create/cleanup field groups in beforeAll/afterAll. + */ +const { test, expect } = require( './fixtures' ); + +const PLUGIN_SLUG = 'secure-custom-fields'; +const ABILITIES_BASE = '/wp-abilities/v1/abilities'; + +const TEST_FIELD_GROUP = { + key: 'group_e2e_field_test', + title: 'E2E Field Test Group', + fields: [], +}; + +const TEST_FIELD = { + key: 'field_e2e_test', + label: 'E2E Test Field', + name: 'e2e_test_field', + type: 'text', +}; + +// Helper functions + +/** + * Check if Abilities API exists (for older WordPress versions). + * + * @param {Object} requestUtils - Playwright request utilities. + * @return {Promise} Whether the Abilities API is available. + */ +async function abilitiesApiExists( requestUtils ) { + try { + await requestUtils.rest( { + method: 'GET', + path: '/wp-abilities/v1', + } ); + return true; + } catch { + return false; + } +} + +/** + * Assert that a REST request throws a "not found" error with 404 status. + * + * @param {Promise} requestPromise - The REST request promise to check. + */ +async function expectNotFound( requestPromise ) { + try { + await requestPromise; + throw new Error( 'Expected not found error but request succeeded' ); + } catch ( error ) { + expect( error.code ).toBe( 'not_found' ); + expect( error.data?.status ).toBe( 404 ); + } +} + +/** + * Assert that a REST request throws an "invalid input" error with 400 status. + * + * @param {Promise} requestPromise - The REST request promise to check. + */ +async function expectInvalidInput( requestPromise ) { + try { + await requestPromise; + throw new Error( 'Expected invalid input error but request succeeded' ); + } catch ( error ) { + expect( error.code ).toBe( 'ability_invalid_input' ); + expect( error.data?.status ).toBe( 400 ); + } +} + +// Field Group API helpers (for setup/cleanup) +const fieldGroupApi = { + create: ( requestUtils, data = TEST_FIELD_GROUP ) => + requestUtils.rest( { + method: 'POST', + path: `${ ABILITIES_BASE }/scf/create-field-group/run`, + data: { input: data }, + } ), + + delete: ( requestUtils, identifier ) => + requestUtils.rest( { + method: 'DELETE', + path: `${ ABILITIES_BASE }/scf/delete-field-group/run`, + params: { 'input[identifier]': identifier }, + } ), + + cleanup: async ( requestUtils, identifier ) => { + try { + await requestUtils.rest( { + method: 'DELETE', + path: `${ ABILITIES_BASE }/scf/delete-field-group/run`, + params: { 'input[identifier]': identifier }, + } ); + } catch { + // Ignore errors - entity may not exist + } + }, +}; + +// Field API helpers +const fieldApi = { + list: ( requestUtils, filter = {} ) => { + const params = { 'input[filter]': '' }; + Object.entries( filter ).forEach( ( [ key, value ] ) => { + params[ `input[filter][${ key }]` ] = value; + } ); + return requestUtils.rest( { + method: 'GET', + path: `${ ABILITIES_BASE }/scf/list-fields/run`, + params, + } ); + }, + + get: ( requestUtils, identifier ) => + requestUtils.rest( { + method: 'GET', + path: `${ ABILITIES_BASE }/scf/get-field/run`, + params: { 'input[identifier]': identifier }, + } ), + + create: ( requestUtils, data ) => + requestUtils.rest( { + method: 'POST', + path: `${ ABILITIES_BASE }/scf/create-field/run`, + data: { input: data }, + } ), + + update: ( requestUtils, input ) => + requestUtils.rest( { + method: 'POST', + path: `${ ABILITIES_BASE }/scf/update-field/run`, + data: { input }, + } ), + + delete: ( requestUtils, identifier ) => + requestUtils.rest( { + method: 'DELETE', + path: `${ ABILITIES_BASE }/scf/delete-field/run`, + params: { 'input[identifier]': identifier }, + } ), + + duplicate: ( requestUtils, identifier, newParentId = null ) => { + const input = { identifier }; + if ( newParentId ) { + input.new_parent_id = newParentId; + } + return requestUtils.rest( { + method: 'POST', + path: `${ ABILITIES_BASE }/scf/duplicate-field/run`, + data: { input }, + } ); + }, + + export: ( requestUtils, identifier ) => + requestUtils.rest( { + method: 'GET', + path: `${ ABILITIES_BASE }/scf/export-field/run`, + params: { 'input[identifier]': identifier }, + } ), + + import: ( requestUtils, data ) => + requestUtils.rest( { + method: 'POST', + path: `${ ABILITIES_BASE }/scf/import-field/run`, + data: { input: data }, + } ), + + cleanup: async ( requestUtils, identifier ) => { + try { + await requestUtils.rest( { + method: 'DELETE', + path: `${ ABILITIES_BASE }/scf/delete-field/run`, + params: { 'input[identifier]': identifier }, + } ); + } catch { + // Ignore errors - entity may not exist + } + }, +}; + +test.describe( 'Field Abilities', () => { + let fieldGroupId; + + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( PLUGIN_SLUG ); + + // Skip all tests if Abilities API is not available + const hasAbilitiesApi = await abilitiesApiExists( requestUtils ); + test.skip( + ! hasAbilitiesApi, + 'Abilities API not available in this WordPress version' + ); + + // Create parent field group + await fieldGroupApi.cleanup( requestUtils, TEST_FIELD_GROUP.key ); + const fieldGroup = await fieldGroupApi.create( requestUtils ); + fieldGroupId = fieldGroup.ID; + } ); + + test.afterAll( async ( { requestUtils } ) => { + await fieldGroupApi.cleanup( requestUtils, TEST_FIELD_GROUP.key ); + } ); + + // List fields - GET with query params (readonly) + + test.describe( 'scf/list-fields', () => { + test.beforeEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + await fieldApi.create( requestUtils, { + ...TEST_FIELD, + parent: fieldGroupId, + } ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + } ); + + test( 'should list all SCF fields', async ( { requestUtils } ) => { + const result = await fieldApi.list( requestUtils ); + + expect( Array.isArray( result ) ).toBe( true ); + expect( + result.some( ( item ) => item.key === TEST_FIELD.key ) + ).toBe( true ); + } ); + + test( 'should support filter by type', async ( { requestUtils } ) => { + const result = await fieldApi.list( requestUtils, { type: 'text' } ); + + expect( Array.isArray( result ) ).toBe( true ); + expect( + result.every( ( item ) => item.type === 'text' ) + ).toBe( true ); + } ); + + test( 'should support filter by parent', async ( { requestUtils } ) => { + const result = await fieldApi.list( requestUtils, { + parent: fieldGroupId, + } ); + + expect( Array.isArray( result ) ).toBe( true ); + expect( + result.some( ( item ) => item.key === TEST_FIELD.key ) + ).toBe( true ); + } ); + } ); + + // Get field - GET with query params (readonly) + + test.describe( 'scf/get-field', () => { + test.beforeEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + await fieldApi.create( requestUtils, { + ...TEST_FIELD, + parent: fieldGroupId, + } ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + } ); + + test( 'should get a field by key', async ( { requestUtils } ) => { + const result = await fieldApi.get( requestUtils, TEST_FIELD.key ); + + expect( result ).toHaveProperty( 'key', TEST_FIELD.key ); + expect( result ).toHaveProperty( 'label', TEST_FIELD.label ); + expect( result ).toHaveProperty( 'name', TEST_FIELD.name ); + expect( result ).toHaveProperty( 'type', TEST_FIELD.type ); + } ); + + test( 'should return error for non-existent field', async ( { + requestUtils, + } ) => { + await expectNotFound( + fieldApi.get( requestUtils, 'field_nonexistent_abc' ) + ); + } ); + } ); + + // Export field - GET with query params (readonly) + + test.describe( 'scf/export-field', () => { + test.beforeEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + await fieldApi.create( requestUtils, { + ...TEST_FIELD, + parent: fieldGroupId, + } ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + } ); + + test( 'should export a field as JSON', async ( { requestUtils } ) => { + const result = await fieldApi.export( requestUtils, TEST_FIELD.key ); + + expect( result ).toHaveProperty( 'key', TEST_FIELD.key ); + expect( result ).toHaveProperty( 'label', TEST_FIELD.label ); + expect( result ).toHaveProperty( 'type', TEST_FIELD.type ); + // Internal fields should be stripped + expect( result ).not.toHaveProperty( 'ID' ); + } ); + + test( 'should return error for non-existent field', async ( { + requestUtils, + } ) => { + await expectNotFound( + fieldApi.export( requestUtils, 'field_nonexistent_export' ) + ); + } ); + } ); + + // Create field - POST with body + + test.describe( 'scf/create-field', () => { + test.beforeEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + } ); + + test( 'should create a new field', async ( { requestUtils } ) => { + const result = await fieldApi.create( requestUtils, { + ...TEST_FIELD, + parent: fieldGroupId, + } ); + + expect( result ).toHaveProperty( 'key', TEST_FIELD.key ); + expect( result ).toHaveProperty( 'label', TEST_FIELD.label ); + expect( result ).toHaveProperty( 'name', TEST_FIELD.name ); + expect( result ).toHaveProperty( 'type', TEST_FIELD.type ); + expect( result ).toHaveProperty( 'ID' ); + } ); + + test( 'should return error when required fields are missing', async ( { + requestUtils, + } ) => { + await expectInvalidInput( + fieldApi.create( requestUtils, { + label: 'Missing Required Fields', + } ) + ); + } ); + } ); + + // Update field - POST with body + + test.describe( 'scf/update-field', () => { + let testFieldId; + + test.beforeEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + const result = await fieldApi.create( requestUtils, { + ...TEST_FIELD, + parent: fieldGroupId, + } ); + testFieldId = result.ID; + } ); + + test.afterEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + } ); + + test( 'should update an existing field', async ( { requestUtils } ) => { + const result = await fieldApi.update( requestUtils, { + ID: testFieldId, + label: 'Updated Label', + } ); + + expect( result ).toHaveProperty( 'label', 'Updated Label' ); + } ); + + test( 'should return error for non-existent field ID', async ( { + requestUtils, + } ) => { + await expectNotFound( + fieldApi.update( requestUtils, { + ID: 999999, + label: 'Should Fail', + } ) + ); + } ); + + test( 'should return error when ID is missing', async ( { + requestUtils, + } ) => { + await expectInvalidInput( + fieldApi.update( requestUtils, { label: 'Missing ID' } ) + ); + } ); + } ); + + // Delete field - DELETE with query params (destructive) + + test.describe( 'scf/delete-field', () => { + test.beforeEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + await fieldApi.create( requestUtils, { + ...TEST_FIELD, + parent: fieldGroupId, + } ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + } ); + + test( 'should delete an existing field', async ( { requestUtils } ) => { + const result = await fieldApi.delete( requestUtils, TEST_FIELD.key ); + expect( result ).toBe( true ); + + // Verify it's actually deleted + await expectNotFound( fieldApi.get( requestUtils, TEST_FIELD.key ) ); + } ); + + test( 'should return error for non-existent field', async ( { + requestUtils, + } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + + await expectNotFound( + fieldApi.delete( requestUtils, 'field_nonexistent_xyz' ) + ); + } ); + } ); + + // Duplicate field - POST with body + + test.describe( 'scf/duplicate-field', () => { + let duplicatedKey; + + test.beforeEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + await fieldApi.create( requestUtils, { + ...TEST_FIELD, + parent: fieldGroupId, + } ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + if ( duplicatedKey ) { + await fieldApi.cleanup( requestUtils, duplicatedKey ); + duplicatedKey = null; + } + } ); + + test( 'should duplicate an existing field', async ( { + requestUtils, + } ) => { + const result = await fieldApi.duplicate( + requestUtils, + TEST_FIELD.key + ); + duplicatedKey = result.key; + + expect( result ).toHaveProperty( 'key' ); + expect( result.key ).not.toBe( TEST_FIELD.key ); + expect( result ).toHaveProperty( 'type', TEST_FIELD.type ); + } ); + + test( 'should return error for non-existent field', async ( { + requestUtils, + } ) => { + await expectNotFound( + fieldApi.duplicate( requestUtils, 'field_nonexistent_dup' ) + ); + } ); + } ); + + // Import field - POST with body + + test.describe( 'scf/import-field', () => { + test.beforeEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await fieldApi.cleanup( requestUtils, TEST_FIELD.key ); + } ); + + test( 'should import a field from JSON', async ( { requestUtils } ) => { + const result = await fieldApi.import( requestUtils, { + ...TEST_FIELD, + parent: fieldGroupId, + } ); + + expect( result ).toHaveProperty( 'key', TEST_FIELD.key ); + expect( result ).toHaveProperty( 'label', TEST_FIELD.label ); + expect( result ).toHaveProperty( 'type', TEST_FIELD.type ); + } ); + + test( 'should return error when required fields are missing', async ( { + requestUtils, + } ) => { + await expectInvalidInput( + fieldApi.import( requestUtils, { + label: 'Missing Required Fields', + } ) + ); + } ); + } ); +} ); diff --git a/tests/php/includes/abilities/SCFFieldAbilitiesTest.php b/tests/php/includes/abilities/SCFFieldAbilitiesTest.php new file mode 100644 index 00000000..b9ca8a7e --- /dev/null +++ b/tests/php/includes/abilities/SCFFieldAbilitiesTest.php @@ -0,0 +1,677 @@ + 'field_phpunit_test', + 'label' => 'PHPUnit Test Field', + 'name' => 'phpunit_test', + 'type' => 'text', + 'parent' => 123, + ); + + /** + * Reusable mock entity for mocked callback tests + * + * @var array + */ + private $mock_field = array( + 'ID' => 456, + 'key' => 'field_test_key', + 'label' => 'Test Field', + 'name' => 'test_field', + 'type' => 'text', + 'parent' => 123, + ); + + /** + * Parent exists filter callback for testing. + * + * @var callable|null + */ + private $parent_exists_filter = null; + + /** + * Setup test fixtures + */ + public function setUp(): void { + parent::setUp(); + $this->abilities = acf_get_instance( 'SCF_Field_Abilities' ); + // Mock the parent as existing by default for create/import tests. + $this->set_parent_exists_filter( fn( $exists, $parent_id ) => 123 === $parent_id ? true : $exists ); + } + + /** + * Teardown test fixtures + */ + public function tearDown(): void { + parent::tearDown(); + $this->remove_parent_exists_filter(); + } + + /** + * Helper to set the parent exists filter for testing. + * + * @param callable $callback The filter callback. + */ + private function set_parent_exists_filter( callable $callback ) { + $this->remove_parent_exists_filter(); + $this->parent_exists_filter = $callback; + add_filter( 'scf_field_parent_exists', $this->parent_exists_filter, 10, 2 ); + } + + /** + * Helper to remove the parent exists filter. + */ + private function remove_parent_exists_filter() { + if ( $this->parent_exists_filter ) { + remove_filter( 'scf_field_parent_exists', $this->parent_exists_filter, 10 ); + $this->parent_exists_filter = null; + } + } + + /** + * Helper to inject a mock manager into the abilities object. + * + * @param array $method_returns Map of method names to return values. + * @return \PHPUnit\Framework\MockObject\MockObject The mock manager. + */ + private function inject_mock_manager( array $method_returns ) { + $mock_manager = $this->createMock( SCF_Field_Manager::class ); + + foreach ( $method_returns as $method => $return_value ) { + $mock_manager->method( $method )->willReturn( $return_value ); + } + + $reflection = new ReflectionClass( SCF_Field_Abilities::class ); + $property = $reflection->getProperty( 'manager' ); + $property->setAccessible( true ); + $property->setValue( $this->abilities, $mock_manager ); + + return $mock_manager; + } + + /** + * Helper to assert a callback returns a specific WP_Error code. + * + * @param array $mock_returns Mock method return values. + * @param callable $callback The callback to invoke. + * @param array $input Input for the callback. + * @param string $expected_code Expected error code. + */ + private function assert_callback_error( array $mock_returns, callable $callback, array $input, string $expected_code ) { + $this->inject_mock_manager( $mock_returns ); + $result = $callback( $input ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( $expected_code, $result->get_error_code() ); + } + + // Constructor tests. + + /** + * Test constructor registers WordPress action hooks + */ + public function test_constructor_registers_action_hooks() { + $fresh_instance = new SCF_Field_Abilities(); + + $this->assertNotFalse( + has_action( 'wp_abilities_api_categories_init', array( $fresh_instance, 'register_categories' ) ), + 'Should register wp_abilities_api_categories_init action' + ); + $this->assertNotFalse( + has_action( 'wp_abilities_api_init', array( $fresh_instance, 'register_abilities' ) ), + 'Should register wp_abilities_api_init action' + ); + } + + /** + * Test categories are registered when wp_abilities_api_categories_init fires + */ + public function test_categories_registered_on_action() { + global $mock_registered_ability_categories; + $mock_registered_ability_categories = array(); + + do_action( 'wp_abilities_api_categories_init' ); + + $this->assertArrayHasKey( + 'scf-fields', + $mock_registered_ability_categories, + 'Category should be registered when action fires' + ); + } + + /** + * Test abilities are registered when wp_abilities_api_init fires + */ + public function test_abilities_registered_on_action() { + global $mock_registered_abilities; + $mock_registered_abilities = array(); + + do_action( 'wp_abilities_api_init' ); + + $expected_abilities = array( + 'list-fields', + 'get-field', + 'create-field', + 'update-field', + 'delete-field', + 'duplicate-field', + 'export-field', + 'import-field', + ); + + foreach ( $expected_abilities as $ability_name ) { + $this->assertArrayHasKey( + "scf/$ability_name", + $mock_registered_abilities, + "Ability '$ability_name' should be registered" + ); + } + } + + // List callback tests. + + /** + * Test list_callback returns filtered fields + */ + public function test_list_callback_returns_filtered_fields() { + $fields = array( $this->mock_field ); + $this->inject_mock_manager( + array( + 'get_posts' => $fields, + 'filter_posts' => $fields, + ) + ); + + $result = $this->abilities->list_callback( array( 'filter' => array( 'type' => 'text' ) ) ); + + $this->assertIsArray( $result ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'field_test_key', $result[0]['key'] ); + } + + /** + * Test list_callback with empty filter returns all fields + */ + public function test_list_callback_with_empty_filter() { + $fields = array( $this->mock_field ); + $this->inject_mock_manager( + array( + 'get_posts' => $fields, + 'filter_posts' => $fields, + ) + ); + + $result = $this->abilities->list_callback( array() ); + + $this->assertIsArray( $result ); + } + + // Get callback tests. + + /** + * Test get_callback returns field when found + */ + public function test_get_callback_returns_field_when_found() { + $this->inject_mock_manager( array( 'get_post' => $this->mock_field ) ); + + $result = $this->abilities->get_callback( array( 'identifier' => 'field_test_key' ) ); + + $this->assertIsArray( $result ); + $this->assertEquals( 'field_test_key', $result['key'] ); + } + + /** + * Test get_callback returns error when field not found + */ + public function test_get_callback_returns_error_when_not_found() { + $this->assert_callback_error( + array( 'get_post' => false ), + array( $this->abilities, 'get_callback' ), + array( 'identifier' => 'nonexistent' ), + 'not_found' + ); + } + + // Create callback tests. + + /** + * Test create_callback creates new field + */ + public function test_create_callback_creates_new_field() { + $this->inject_mock_manager( + array( + 'get_post' => false, + 'update_post' => $this->mock_field, + ) + ); + + $result = $this->abilities->create_callback( $this->test_field ); + + $this->assertIsArray( $result ); + $this->assertEquals( 'field_test_key', $result['key'] ); + } + + /** + * Test create_callback returns error when field with key already exists + */ + public function test_create_callback_returns_error_when_key_exists() { + $this->assert_callback_error( + array( 'get_post' => $this->mock_field ), + array( $this->abilities, 'create_callback' ), + $this->test_field, + 'already_exists' + ); + } + + /** + * Test create_callback returns error when creation fails + */ + public function test_create_callback_returns_error_when_creation_fails() { + $this->assert_callback_error( + array( + 'get_post' => false, + 'update_post' => false, + ), + array( $this->abilities, 'create_callback' ), + $this->test_field, + 'create_failed' + ); + } + + // Update callback tests. + + /** + * Test update_callback updates existing field + */ + public function test_update_callback_updates_existing_field() { + $updated_field = $this->mock_field; + $updated_field['label'] = 'Updated Label'; + + $this->inject_mock_manager( + array( + 'get_post' => $this->mock_field, + 'update_post' => $updated_field, + ) + ); + + $result = $this->abilities->update_callback( + array( + 'ID' => 456, + 'label' => 'Updated Label', + ) + ); + + $this->assertIsArray( $result ); + $this->assertEquals( 'Updated Label', $result['label'] ); + } + + /** + * Test update_callback returns error when field not found + */ + public function test_update_callback_returns_error_when_not_found() { + $this->assert_callback_error( + array( 'get_post' => false ), + array( $this->abilities, 'update_callback' ), + array( + 'ID' => 999, + 'label' => 'New Label', + ), + 'not_found' + ); + } + + /** + * Test update_callback returns error when update fails + */ + public function test_update_callback_returns_error_when_update_fails() { + $this->assert_callback_error( + array( + 'get_post' => $this->mock_field, + 'update_post' => false, + ), + array( $this->abilities, 'update_callback' ), + array( + 'ID' => 456, + 'label' => 'New Label', + ), + 'update_failed' + ); + } + + // Delete callback tests. + + /** + * Test delete_callback deletes field + */ + public function test_delete_callback_deletes_field() { + $this->inject_mock_manager( + array( + 'get_post' => $this->mock_field, + 'delete_post' => true, + ) + ); + + $result = $this->abilities->delete_callback( array( 'identifier' => 'field_test_key' ) ); + + $this->assertTrue( $result ); + } + + /** + * Test delete_callback returns error when field not found + */ + public function test_delete_callback_returns_error_when_not_found() { + $this->assert_callback_error( + array( 'get_post' => false ), + array( $this->abilities, 'delete_callback' ), + array( 'identifier' => 'nonexistent' ), + 'not_found' + ); + } + + /** + * Test delete_callback returns error when deletion fails + */ + public function test_delete_callback_returns_error_when_deletion_fails() { + $this->assert_callback_error( + array( + 'get_post' => $this->mock_field, + 'delete_post' => false, + ), + array( $this->abilities, 'delete_callback' ), + array( 'identifier' => 'field_test_key' ), + 'delete_failed' + ); + } + + // Duplicate callback tests. + + /** + * Test duplicate_callback duplicates field + */ + public function test_duplicate_callback_duplicates_field() { + $duplicated_field = $this->mock_field; + $duplicated_field['key'] = 'field_duplicated'; + + $this->inject_mock_manager( + array( + 'get_post' => $this->mock_field, + 'duplicate_post' => $duplicated_field, + ) + ); + + $result = $this->abilities->duplicate_callback( array( 'identifier' => 'field_test_key' ) ); + + $this->assertIsArray( $result ); + $this->assertEquals( 'field_duplicated', $result['key'] ); + } + + /** + * Test duplicate_callback returns error when field not found + */ + public function test_duplicate_callback_returns_error_when_not_found() { + $this->assert_callback_error( + array( 'get_post' => false ), + array( $this->abilities, 'duplicate_callback' ), + array( 'identifier' => 'nonexistent' ), + 'not_found' + ); + } + + /** + * Test duplicate_callback returns error when duplication fails + */ + public function test_duplicate_callback_returns_error_when_duplication_fails() { + $this->assert_callback_error( + array( + 'get_post' => $this->mock_field, + 'duplicate_post' => false, + ), + array( $this->abilities, 'duplicate_callback' ), + array( 'identifier' => 'field_test_key' ), + 'duplicate_failed' + ); + } + + /** + * Test duplicate_callback returns error when new_parent_id is invalid + */ + public function test_duplicate_callback_returns_error_when_new_parent_id_invalid() { + $this->inject_mock_manager( + array( + 'get_post' => $this->mock_field, + ) + ); + + $result = $this->abilities->duplicate_callback( + array( + 'identifier' => 'field_test_key', + 'new_parent_id' => 999999, // Non-existent field group ID. + ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'invalid_new_parent_id', $result->get_error_code() ); + } + + // Export callback tests. + + /** + * Test export_callback exports field + */ + public function test_export_callback_exports_field() { + $exported_field = $this->mock_field; + unset( $exported_field['ID'] ); + + $this->inject_mock_manager( + array( + 'get_post' => $this->mock_field, + 'prepare_post_for_export' => $exported_field, + ) + ); + + $result = $this->abilities->export_callback( array( 'identifier' => 'field_test_key' ) ); + + $this->assertIsArray( $result ); + $this->assertArrayNotHasKey( 'ID', $result ); + } + + /** + * Test export_callback returns error when field not found + */ + public function test_export_callback_returns_error_when_not_found() { + $this->assert_callback_error( + array( 'get_post' => false ), + array( $this->abilities, 'export_callback' ), + array( 'identifier' => 'nonexistent' ), + 'not_found' + ); + } + + /** + * Test export_callback returns error when export fails + */ + public function test_export_callback_returns_error_when_export_fails() { + $this->assert_callback_error( + array( + 'get_post' => $this->mock_field, + 'prepare_post_for_export' => false, + ), + array( $this->abilities, 'export_callback' ), + array( 'identifier' => 'field_test_key' ), + 'export_failed' + ); + } + + // Import callback tests. + + /** + * Test import_callback imports field + */ + public function test_import_callback_imports_field() { + $this->inject_mock_manager( array( 'import_post' => $this->mock_field ) ); + + $result = $this->abilities->import_callback( $this->test_field ); + + $this->assertIsArray( $result ); + $this->assertEquals( 'field_test_key', $result['key'] ); + } + + /** + * Test import_callback returns error when import fails + */ + public function test_import_callback_returns_error_when_import_fails() { + $this->assert_callback_error( + array( 'import_post' => false ), + array( $this->abilities, 'import_callback' ), + $this->test_field, + 'import_failed' + ); + } + + /** + * Test import_callback returns error when parent not found + */ + public function test_import_callback_returns_error_when_parent_not_found() { + // Mock the parent as NOT existing. + $this->set_parent_exists_filter( fn( $exists, $parent_id ) => 999 === $parent_id ? false : $exists ); + + $field_with_bad_parent = $this->test_field; + $field_with_bad_parent['parent'] = 999; + + $result = $this->abilities->import_callback( $field_with_bad_parent ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'parent_not_found', $result->get_error_code() ); + } + + // Parent existence tests. + + /** + * Test parent_exists uses acf_get_field_group when filter returns null + */ + public function test_parent_exists_uses_real_lookup_when_filter_returns_null() { + // Remove the filter so the real lookup is used. + $this->remove_parent_exists_filter(); + + // Create a real field group in the database. + $field_group_id = wp_insert_post( + array( + 'post_title' => 'Test Field Group', + 'post_type' => 'acf-field-group', + 'post_status' => 'publish', + ) + ); + + // Inject a mock manager that doesn't interfere with parent checks. + $this->inject_mock_manager( array( 'update_post' => $this->mock_field ) ); + + // Try to create a field with the real field group as parent. + $field_data = array( + 'key' => 'field_test_real_parent', + 'label' => 'Test Field', + 'name' => 'test_real_parent', + 'type' => 'text', + 'parent' => $field_group_id, + ); + + $result = $this->abilities->create_callback( $field_data ); + + // Should succeed because the real field group exists. + $this->assertIsArray( $result ); + + // Cleanup. + wp_delete_post( $field_group_id, true ); + } + + /** + * Test parent_exists returns false for non-existent parent when filter returns null + */ + public function test_parent_exists_returns_false_for_nonexistent_parent() { + // Remove the filter so the real lookup is used. + $this->remove_parent_exists_filter(); + + // Try to create a field with a non-existent parent. + $field_data = array( + 'key' => 'field_test_bad_parent', + 'label' => 'Test Field', + 'name' => 'test_bad_parent', + 'type' => 'text', + 'parent' => 999999, // Non-existent ID. + ); + + $result = $this->abilities->create_callback( $field_data ); + + // Should fail because the parent doesn't exist. + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'parent_not_found', $result->get_error_code() ); + } + + // Schema resolution tests. + + /** + * Test resolve_schema_refs triggers _doing_it_wrong for missing definition + */ + public function test_resolve_schema_refs_triggers_doing_it_wrong_for_missing_definition() { + $reflection = new ReflectionClass( SCF_Field_Abilities::class ); + + // First, inject a custom field schema with limited definitions. + $property = $reflection->getProperty( 'field_schema' ); + $property->setAccessible( true ); + $property->setValue( + $this->abilities, + array( + 'definitions' => array( + 'existingDef' => array( 'type' => 'string' ), + ), + ) + ); + + $method = $reflection->getMethod( 'resolve_schema_refs' ); + $method->setAccessible( true ); + + // Try to resolve a $ref to a non-existent definition. + $schema = array( + '$ref' => '#/definitions/nonExistentDef', + ); + + $result = $method->invoke( $this->abilities, $schema ); + + // Should return original schema when definition not found. + $this->assertEquals( $schema, $result ); + } +} diff --git a/tests/php/post-types/SCFFieldManagerTest.php b/tests/php/post-types/SCFFieldManagerTest.php index 1da4bcb7..e76b9d89 100644 --- a/tests/php/post-types/SCFFieldManagerTest.php +++ b/tests/php/post-types/SCFFieldManagerTest.php @@ -39,25 +39,25 @@ public function setUp(): void { 'key' => 'field_1', 'name' => 'text_field', 'type' => 'text', - 'parent' => 'group_123', + 'parent' => 123, ), array( 'key' => 'field_2', 'name' => 'image_field', 'type' => 'image', - 'parent' => 'group_123', + 'parent' => 123, ), array( 'key' => 'field_3', 'name' => 'another_text', 'type' => 'text', - 'parent' => 'group_456', + 'parent' => 456, ), array( 'key' => 'field_4', 'name' => 'email_field', 'type' => 'email', - 'parent' => 'group_456', + 'parent' => 456, ), ); } @@ -91,11 +91,11 @@ public function test_filter_posts_no_filters() { * Test filter_posts by parent. */ public function test_filter_posts_by_parent() { - $result = $this->manager->filter_posts( $this->sample_fields, array( 'parent' => 'group_123' ) ); + $result = $this->manager->filter_posts( $this->sample_fields, array( 'parent' => 123 ) ); $this->assertCount( 2, $result ); foreach ( $result as $field ) { - $this->assertEquals( 'group_123', $field['parent'] ); + $this->assertEquals( 123, $field['parent'] ); } } @@ -128,7 +128,7 @@ public function test_filter_posts_multiple_filters() { $result = $this->manager->filter_posts( $this->sample_fields, array( - 'parent' => 'group_123', + 'parent' => 123, 'type' => 'text', ) ); @@ -149,7 +149,7 @@ public function test_filter_posts_no_matches() { * Test filter_posts reindexes keys. */ public function test_filter_posts_reindexes_keys() { - $result = $this->manager->filter_posts( $this->sample_fields, array( 'parent' => 'group_456' ) ); + $result = $this->manager->filter_posts( $this->sample_fields, array( 'parent' => 456 ) ); $this->assertArrayHasKey( 0, $result ); $this->assertArrayHasKey( 1, $result ); @@ -164,6 +164,70 @@ public function test_filter_posts_empty_input() { $this->assertCount( 0, $result ); } + /** + * Test filter_posts converts parent key to ID. + * + * This tests filtering sub-fields by their parent field's key (e.g., sub-fields of a repeater). + * Uses cache to simulate acf_get_field_post behavior in unit tests. + */ + public function test_filter_posts_converts_parent_key_to_id() { + $parent_field_key = 'field_parent_repeater'; + + // Create a mock post for get_post() to return. + $parent_field_id = wp_insert_post( + array( + 'post_type' => 'acf-field', + 'post_title' => 'Parent Repeater', + 'post_name' => $parent_field_key, + 'post_status' => 'publish', + ) + ); + + // Pre-populate the cache so acf_get_field_post finds the field by key. + $cache_key = acf_cache_key( "acf_get_field_post:key:$parent_field_key" ); + wp_cache_set( $cache_key, $parent_field_id, 'secure-custom-fields' ); + + // Filter using the parent field's key (string), not the numeric ID. + $sub_fields = array( + array( + 'key' => 'field_sub_1', + 'name' => 'sub_field_1', + 'type' => 'text', + 'parent' => $parent_field_id, + ), + array( + 'key' => 'field_sub_2', + 'name' => 'sub_field_2', + 'type' => 'text', + 'parent' => 999, // Different parent. + ), + ); + + $result = $this->manager->filter_posts( $sub_fields, array( 'parent' => $parent_field_key ) ); + + $this->assertCount( 1, $result ); + $this->assertEquals( 'field_sub_1', $result[0]['key'] ); + } + + /** + * Test filter_posts returns empty when parent key not found. + */ + public function test_filter_posts_returns_empty_when_parent_key_not_found() { + $fields = array( + array( + 'key' => 'field_1', + 'name' => 'test_field', + 'type' => 'text', + 'parent' => 123, + ), + ); + + // Filter using a non-existent field key - should convert to 0 and match nothing. + $result = $this->manager->filter_posts( $fields, array( 'parent' => 'field_nonexistent_key' ) ); + + $this->assertCount( 0, $result ); + } + /** * Test post_type property. */ @@ -248,43 +312,6 @@ function () use ( &$called ) { $this->assertTrue( $called ); } - /** - * Test trash_post calls acf_trash_field. - */ - public function test_trash_post_calls_acf_trash_field() { - $field = $this->create_test_field( 'trash_test' ); - $called = false; - - add_action( - 'acf/trash_field', - function () use ( &$called ) { - $called = true; - } - ); - - $this->manager->trash_post( $field['ID'] ); - $this->assertTrue( $called ); - } - - /** - * Test untrash_post calls acf_untrash_field. - */ - public function test_untrash_post_calls_acf_untrash_field() { - $field = $this->create_test_field( 'untrash_test' ); - acf_trash_field( $field['ID'] ); - $called = false; - - add_action( - 'acf/untrash_field', - function () use ( &$called ) { - $called = true; - } - ); - - $this->manager->untrash_post( $field['ID'] ); - $this->assertTrue( $called ); - } - /** * Test get_posts returns fields from all field groups. */