diff --git a/includes/abilities/abilities-integration.php b/includes/abilities/class-scf-abilities-integration.php similarity index 93% rename from includes/abilities/abilities-integration.php rename to includes/abilities/class-scf-abilities-integration.php index 3a8e3d17..b25e6b1f 100644 --- a/includes/abilities/abilities-integration.php +++ b/includes/abilities/class-scf-abilities-integration.php @@ -39,6 +39,7 @@ public function init() { return; } + acf_include( 'includes/abilities/class-scf-internal-post-type-abilities.php' ); acf_include( 'includes/abilities/class-scf-post-type-abilities.php' ); acf_include( 'includes/abilities/class-scf-taxonomy-abilities.php' ); } diff --git a/includes/abilities/class-scf-internal-post-type-abilities.php b/includes/abilities/class-scf-internal-post-type-abilities.php new file mode 100644 index 00000000..53e9cd7b --- /dev/null +++ b/includes/abilities/class-scf-internal-post-type-abilities.php @@ -0,0 +1,785 @@ +internal_post_type ) ) { + _doing_it_wrong( __METHOD__, 'Child classes must set $internal_post_type property.', '6.7.0' ); + return; + } + + $validator = acf_get_instance( 'SCF_JSON_Schema_Validator' ); + if ( ! $validator->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 internal post type instance. + * + * @return ACF_Internal_Post_Type + */ + private function instance() { + if ( null === $this->instance ) { + $this->instance = acf_get_internal_post_type_instance( $this->internal_post_type ); + } + return $this->instance; + } + + /** + * Gets entity name, e.g., 'post type', 'taxonomy'. + * + * @return string + */ + private function entity_name() { + return str_replace( '_', ' ', $this->instance()->hook_name ); + } + + /** + * Gets entity name plural, e.g., 'post types', 'taxonomies'. + * + * @return string + */ + private function entity_name_plural() { + return str_replace( '_', ' ', $this->instance()->hook_name_plural ); + } + + /** + * Gets schema name, e.g., 'post-type', 'taxonomy'. + * + * @return string + */ + private function schema_name() { + return str_replace( '_', '-', $this->instance()->hook_name ); + } + + /** + * Gets ability category, e.g., 'scf-post-types', 'scf-taxonomies'. + * + * @return string + */ + private function ability_category() { + return 'scf-' . str_replace( '_', '-', $this->instance()->hook_name_plural ); + } + + /** + * Gets ability name for an action. + * + * @param string $action E.g., 'list', 'get', 'create'. + * @return string E.g., 'scf/list-post-types', 'scf/get-post-type'. + */ + private function ability_name( $action ) { + $slug = str_replace( '_', '-', 'list' === $action ? $this->instance()->hook_name_plural : $this->instance()->hook_name ); + return 'scf/' . $action . '-' . $slug; + } + + // Schema methods. + + /** + * Gets the entity schema from JSON schema file. + * + * @return array + */ + private function get_entity_schema() { + if ( null === $this->entity_schema ) { + $validator = new SCF_JSON_Schema_Validator(); + $schema = $validator->load_schema( $this->schema_name() ); + + // Convert hook_name to camelCase for schema definition key (post_type → postType). + $def_key = lcfirst( str_replace( ' ', '', ucwords( $this->entity_name() ) ) ); + $this->entity_schema = json_decode( wp_json_encode( $schema->definitions->$def_key ), true ); + } + return $this->entity_schema; + } + + /** + * Gets the SCF identifier schema. + * + * @return array + */ + private function get_scf_identifier_schema() { + if ( null === $this->scf_identifier_schema ) { + $validator = new SCF_JSON_Schema_Validator(); + $this->scf_identifier_schema = json_decode( wp_json_encode( $validator->load_schema( 'scf-identifier' ) ), true ); + } + return $this->scf_identifier_schema; + } + + /** + * Gets the internal fields schema. + * + * @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->internalFields ), true ); + } + + /** + * Gets the entity schema merged with internal fields. + * + * @return array + */ + private function get_entity_with_internal_fields_schema() { + $schema = $this->get_entity_schema(); + $internal = $this->get_internal_fields_schema(); + $schema['properties'] = array_merge( $schema['properties'], $internal['properties'] ); + return $schema; + } + + // Registration. + + /** + * Registers the ability category. + * + * @return void + */ + public function register_categories() { + wp_register_ability_category( + $this->ability_category(), + array( + 'label' => sprintf( + /* translators: %s: Entity type plural, e.g., 'Post Types' */ + __( 'SCF %s', 'secure-custom-fields' ), + ucwords( $this->entity_name_plural() ) + ), + 'description' => sprintf( + /* translators: %s: Entity type plural, e.g., 'post types' */ + __( 'Abilities for managing Secure Custom Fields %s.', 'secure-custom-fields' ), + $this->entity_name_plural() + ), + ) + ); + } + + /** + * Registers all abilities for this entity type. + * + * @return void + */ + 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. + * + * @return void + */ + private function register_list_ability() { + wp_register_ability( + $this->ability_name( 'list' ), + array( + 'label' => sprintf( + /* translators: %s: Entity type plural */ + __( 'List %s', 'secure-custom-fields' ), + ucwords( $this->entity_name_plural() ) + ), + 'description' => sprintf( + /* translators: %s: Entity type plural */ + __( 'Retrieves a list of SCF %s with optional filtering.', 'secure-custom-fields' ), + $this->entity_name_plural() + ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'list_callback' ), + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + ), + 'permission_callback' => 'scf_current_user_has_capability', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'filter' => array( + 'type' => 'object', + 'description' => __( 'Optional filters to apply to results.', 'secure-custom-fields' ), + 'properties' => array( + 'active' => array( + 'type' => 'boolean', + 'description' => __( 'Filter by active status.', 'secure-custom-fields' ), + ), + ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'array', + 'items' => $this->get_entity_with_internal_fields_schema(), + ), + ) + ); + } + + /** + * Registers the get ability. + * + * @return void + */ + private function register_get_ability() { + wp_register_ability( + $this->ability_name( 'get' ), + array( + 'label' => sprintf( + /* translators: %s: Entity type */ + __( 'Get %s', 'secure-custom-fields' ), + ucwords( $this->entity_name() ) + ), + 'description' => sprintf( + /* translators: %s: Entity type */ + __( 'Retrieves SCF %s configuration by ID or key.', 'secure-custom-fields' ), + $this->entity_name() + ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'get_callback' ), + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + ), + 'permission_callback' => 'scf_current_user_has_capability', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'identifier' => $this->get_scf_identifier_schema(), + ), + 'required' => array( 'identifier' ), + ), + 'output_schema' => $this->get_entity_with_internal_fields_schema(), + ) + ); + } + + /** + * Registers the create ability. + * + * @return void + */ + private function register_create_ability() { + wp_register_ability( + $this->ability_name( 'create' ), + array( + 'label' => sprintf( + /* translators: %s: Entity type */ + __( 'Create %s', 'secure-custom-fields' ), + ucwords( $this->entity_name() ) + ), + 'description' => sprintf( + /* translators: %s: Entity type */ + __( 'Creates a new instance of SCF %s with provided configuration.', 'secure-custom-fields' ), + $this->entity_name() + ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'create_callback' ), + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + ), + 'permission_callback' => 'scf_current_user_has_capability', + 'input_schema' => $this->get_entity_schema(), + 'output_schema' => $this->get_entity_with_internal_fields_schema(), + ) + ); + } + + /** + * Registers the update ability. + * + * @return void + */ + private function register_update_ability() { + $input_schema = $this->get_entity_with_internal_fields_schema(); + $input_schema['required'] = array( 'ID' ); + + wp_register_ability( + $this->ability_name( 'update' ), + array( + 'label' => sprintf( + /* translators: %s: Entity type */ + __( 'Update %s', 'secure-custom-fields' ), + ucwords( $this->entity_name() ) + ), + 'description' => sprintf( + /* translators: %s: Entity type */ + __( 'Updates an existing instance of SCF %s with new configuration.', 'secure-custom-fields' ), + $this->entity_name() + ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'update_callback' ), + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ), + ), + 'permission_callback' => 'scf_current_user_has_capability', + 'input_schema' => $input_schema, + 'output_schema' => $this->get_entity_with_internal_fields_schema(), + ) + ); + } + + /** + * Registers the delete ability. + * + * @return void + */ + private function register_delete_ability() { + wp_register_ability( + $this->ability_name( 'delete' ), + array( + 'label' => sprintf( + /* translators: %s: Entity type */ + __( 'Delete %s', 'secure-custom-fields' ), + ucwords( $this->entity_name() ) + ), + 'description' => sprintf( + /* translators: %s: Entity type */ + __( 'Permanently deletes an instance of SCF %s. This action cannot be undone.', 'secure-custom-fields' ), + $this->entity_name() + ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'delete_callback' ), + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => false, + 'destructive' => true, + 'idempotent' => true, + ), + ), + 'permission_callback' => 'scf_current_user_has_capability', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'identifier' => $this->get_scf_identifier_schema(), + ), + 'required' => array( 'identifier' ), + ), + 'output_schema' => array( + 'type' => 'boolean', + 'description' => sprintf( + /* translators: %s: Entity type */ + __( 'True if %s was deleted successfully.', 'secure-custom-fields' ), + $this->entity_name() + ), + ), + ) + ); + } + + /** + * Registers the duplicate ability. + * + * @return void + */ + private function register_duplicate_ability() { + wp_register_ability( + $this->ability_name( 'duplicate' ), + array( + 'label' => sprintf( + /* translators: %s: Entity type */ + __( 'Duplicate %s', 'secure-custom-fields' ), + ucwords( $this->entity_name() ) + ), + 'description' => sprintf( + /* translators: %s: Entity type */ + __( 'Creates a copy of SCF %s. Duplicate receives a new unique key.', 'secure-custom-fields' ), + $this->entity_name() + ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'duplicate_callback' ), + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + ), + 'permission_callback' => 'scf_current_user_has_capability', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'identifier' => $this->get_scf_identifier_schema(), + 'new_post_id' => array( + 'type' => 'integer', + 'description' => __( 'Optional post ID for duplicate.', 'secure-custom-fields' ), + ), + ), + 'required' => array( 'identifier' ), + ), + 'output_schema' => $this->get_entity_with_internal_fields_schema(), + ) + ); + } + + /** + * Registers the export ability. + * + * @return void + */ + private function register_export_ability() { + wp_register_ability( + $this->ability_name( 'export' ), + array( + 'label' => sprintf( + /* translators: %s: Entity type */ + __( 'Export %s', 'secure-custom-fields' ), + ucwords( $this->entity_name() ) + ), + 'description' => sprintf( + /* translators: %s: Entity type */ + __( 'Exports an instance of SCF %s as JSON for backup or transfer.', 'secure-custom-fields' ), + $this->entity_name() + ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'export_callback' ), + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + ), + 'permission_callback' => 'scf_current_user_has_capability', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'identifier' => $this->get_scf_identifier_schema(), + ), + 'required' => array( 'identifier' ), + ), + 'output_schema' => $this->get_entity_schema(), + ) + ); + } + + /** + * Registers the import ability. + * + * @return void + */ + private function register_import_ability() { + wp_register_ability( + $this->ability_name( 'import' ), + array( + 'label' => sprintf( + /* translators: %s: Entity type */ + __( 'Import %s', 'secure-custom-fields' ), + ucwords( $this->entity_name() ) + ), + 'description' => sprintf( + /* translators: %s: Entity type */ + __( 'Imports an instance of SCF %s from JSON data.', 'secure-custom-fields' ), + $this->entity_name() + ), + 'category' => $this->ability_category(), + 'execute_callback' => array( $this, 'import_callback' ), + 'meta' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true ), + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + ), + 'permission_callback' => 'scf_current_user_has_capability', + 'input_schema' => $this->get_entity_with_internal_fields_schema(), + 'output_schema' => $this->get_entity_with_internal_fields_schema(), + ) + ); + } + + // Callbacks. + + /** + * Handles the list ability callback. + * + * @param array $input The input parameters. + * @return array List of entities. + */ + public function list_callback( $input ) { + $filter = isset( $input['filter'] ) ? $input['filter'] : array(); + return $this->instance()->filter_posts( $this->instance()->get_posts(), $filter ); + } + + /** + * Handles the get ability callback. + * + * @param array $input The input parameters. + * @return array|WP_Error Entity data or error if not found. + */ + public function get_callback( $input ) { + $entity = $this->instance()->get_post( $input['identifier'] ); + if ( ! $entity ) { + return $this->not_found_error(); + } + return $entity; + } + + /** + * Handles the create ability callback. + * + * @param array $input The entity data to create. + * @return array|WP_Error Created entity or error on failure. + */ + public function create_callback( $input ) { + if ( $this->instance()->get_post( $input['key'] ) ) { + return new WP_Error( + 'already_exists', + sprintf( + /* translators: %s: Entity type */ + __( '%s with this key already exists.', 'secure-custom-fields' ), + ucfirst( $this->entity_name() ) + ) + ); + } + + $entity = $this->instance()->update_post( $input ); + if ( ! $entity ) { + return new WP_Error( + 'create_failed', + sprintf( + /* translators: %s: Entity type */ + __( 'Failed to create %s.', 'secure-custom-fields' ), + $this->entity_name() + ) + ); + } + return $entity; + } + + /** + * Handles the update ability callback. + * + * @param array $input The entity data to update. + * @return array|WP_Error Updated entity or error on failure. + */ + public function update_callback( $input ) { + $existing = $this->instance()->get_post( $input['ID'] ); + if ( ! $existing ) { + return $this->not_found_error(); + } + + $entity = $this->instance()->update_post( array_merge( $existing, $input ) ); + if ( ! $entity ) { + return new WP_Error( + 'update_failed', + sprintf( + /* translators: %s: Entity type */ + __( 'Failed to update %s.', 'secure-custom-fields' ), + $this->entity_name() + ) + ); + } + return $entity; + } + + /** + * Handles the delete ability callback. + * + * @param array $input The input parameters. + * @return bool|WP_Error True on success or error on failure. + */ + public function delete_callback( $input ) { + if ( ! $this->instance()->get_post( $input['identifier'] ) ) { + return $this->not_found_error(); + } + + if ( ! $this->instance()->delete_post( $input['identifier'] ) ) { + return new WP_Error( + 'delete_failed', + sprintf( + /* translators: %s: Entity type */ + __( 'Failed to delete %s.', 'secure-custom-fields' ), + $this->entity_name() + ) + ); + } + return true; + } + + /** + * Handles the duplicate ability callback. + * + * @param array $input The input parameters. + * @return array|WP_Error Duplicated entity or error on failure. + */ + public function duplicate_callback( $input ) { + if ( ! $this->instance()->get_post( $input['identifier'] ) ) { + return $this->not_found_error(); + } + + $new_post_id = isset( $input['new_post_id'] ) ? $input['new_post_id'] : 0; + $duplicated = $this->instance()->duplicate_post( $input['identifier'], $new_post_id ); + + if ( ! $duplicated ) { + return new WP_Error( + 'duplicate_failed', + sprintf( + /* translators: %s: Entity type */ + __( 'Failed to duplicate %s.', 'secure-custom-fields' ), + $this->entity_name() + ) + ); + } + return $duplicated; + } + + /** + * Handles the export ability callback. + * + * @param array $input The input parameters. + * @return array|WP_Error Exported entity data or error on failure. + */ + public function export_callback( $input ) { + $entity = $this->instance()->get_post( $input['identifier'] ); + if ( ! $entity ) { + return $this->not_found_error(); + } + + $export = $this->instance()->prepare_post_for_export( $entity ); + if ( ! $export ) { + return new WP_Error( + 'export_failed', + sprintf( + /* translators: %s: Entity type */ + __( 'Failed to export %s.', 'secure-custom-fields' ), + $this->entity_name() + ) + ); + } + return $export; + } + + /** + * Handles the import ability callback. + * + * @param array|object $input The entity data to import. + * @return array|WP_Error Imported entity or error on failure. + */ + public function import_callback( $input ) { + $imported = $this->instance()->import_post( $input ); + if ( ! $imported ) { + return new WP_Error( + 'import_failed', + sprintf( + /* translators: %s: Entity type */ + __( 'Failed to import %s.', 'secure-custom-fields' ), + $this->entity_name() + ) + ); + } + return $imported; + } + + /** + * Creates a not found error. + * + * @return WP_Error + */ + private function not_found_error() { + return new WP_Error( + 'not_found', + sprintf( + /* translators: %s: Entity type */ + __( '%s not found.', 'secure-custom-fields' ), + ucfirst( $this->entity_name() ) + ), + array( 'status' => 404 ) + ); + } + } + +endif; diff --git a/includes/abilities/class-scf-post-type-abilities.php b/includes/abilities/class-scf-post-type-abilities.php index cc180f02..f93d01ed 100644 --- a/includes/abilities/class-scf-post-type-abilities.php +++ b/includes/abilities/class-scf-post-type-abilities.php @@ -24,626 +24,17 @@ * * @since 6.6.0 */ - class SCF_Post_Type_Abilities { + class SCF_Post_Type_Abilities extends SCF_Internal_Post_Type_Abilities { /** - * Post type schema to reuse across ability registrations. + * The internal post type identifier. * - * @var object|null + * @var string */ - private $post_type_schema = null; - - /** - * SCF identifier schema to reuse across ability registrations. - * - * @var object|null - */ - private $scf_identifier_schema = null; - - /** - * Constructor. - * - * @since 6.6.0 - */ - public function __construct() { - $validator = acf_get_instance( 'SCF_JSON_Schema_Validator' ); - - // Only register abilities if schemas are available - if ( ! $validator->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' ) ); - } - - /** - * Get the SCF post type schema, loading it once and caching for reuse. - * - * @since 6.6.0 - * @return array The post type schema definition. - */ - private function get_post_type_schema() { - if ( null === $this->post_type_schema ) { - $validator = new SCF_JSON_Schema_Validator(); - $schema = $validator->load_schema( 'post-type' ); - - $this->post_type_schema = json_decode( wp_json_encode( $schema->definitions->postType ), true ); - } - - return $this->post_type_schema; - } - - /** - * Get the SCF identifier schema, loading it once and caching for reuse. - * - * @since 6.6.0 - * - * @return array The SCF identifier schema definition. - */ - private function get_scf_identifier_schema() { - if ( null === $this->scf_identifier_schema ) { - $validator = new SCF_JSON_Schema_Validator(); - - $this->scf_identifier_schema = json_decode( wp_json_encode( $validator->load_schema( 'scf-identifier' ) ), true ); - } - - return $this->scf_identifier_schema; - } - - /** - * Get the internal fields schema (ID, _valid, local). - * - * @since 6.6.0 - * @return array The internal fields schema. - */ - 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->internalFields ), true ); - } - - /** - * Get the post type schema extended with internal fields for GET/LIST/CREATE/UPDATE/IMPORT/DUPLICATE operations. - * - * @since 6.6.0 - * - * @return array The extended post type schema with internal fields. - */ - private function get_post_type_with_internal_fields_schema() { - $schema = $this->get_post_type_schema(); - $internal_fields = $this->get_internal_fields_schema(); - $schema['properties'] = array_merge( $schema['properties'], $internal_fields['properties'] ); - - return $schema; - } - - /** - * Register SCF ability categories. - * - * @since 6.6.0 - */ - public function register_categories() { - wp_register_ability_category( - 'scf-post-types', - array( - 'label' => __( 'SCF Post Types', 'secure-custom-fields' ), - 'description' => __( 'Abilities for managing Secure Custom Fields post types.', 'secure-custom-fields' ), - ) - ); - } - - /** - * Register all post type abilities. - * - * @since 6.6.0 - */ - public function register_abilities() { - $this->register_list_post_types_ability(); - $this->register_get_post_type_ability(); - $this->register_create_post_type_ability(); - $this->register_update_post_type_ability(); - $this->register_delete_post_type_ability(); - $this->register_duplicate_post_type_ability(); - $this->register_export_post_type_ability(); - $this->register_import_post_type_ability(); - } - - /** - * Register the list post types ability. - * - * @since 6.6.0 - */ - private function register_list_post_types_ability() { - wp_register_ability( - 'scf/list-post-types', - array( - 'label' => __( 'List Post Types', 'secure-custom-fields' ), - 'description' => __( 'Retrieves a list of all SCF post types with optional filtering.', 'secure-custom-fields' ), - 'category' => 'scf-post-types', - 'execute_callback' => array( $this, 'list_post_types_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => true, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'filter' => array( - 'type' => 'object', - 'description' => __( 'Optional filters to apply to the post type list.', 'secure-custom-fields' ), - 'properties' => array( - 'active' => array( - 'type' => 'boolean', - 'description' => __( 'Filter by active status.', 'secure-custom-fields' ), - ), - 'search' => array( - 'type' => 'string', - 'description' => __( 'Search term to filter post types.', 'secure-custom-fields' ), - ), - ), - ), - ), - ), - 'output_schema' => array( - 'type' => 'array', - 'items' => $this->get_post_type_with_internal_fields_schema(), - ), - ) - ); - } - - /** - * Register the get post type ability. - * - * @since 6.6.0 - */ - private function register_get_post_type_ability() { - wp_register_ability( - 'scf/get-post-type', - array( - 'label' => __( 'Get Post Type', 'secure-custom-fields' ), - 'description' => __( 'Retrieves a specific SCF post type configuration by ID or key.', 'secure-custom-fields' ), - 'category' => 'scf-post-types', - 'execute_callback' => array( $this, 'get_post_type_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => true, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'identifier' => $this->get_scf_identifier_schema(), - ), - 'required' => array( 'identifier' ), - ), - 'output_schema' => $this->get_post_type_with_internal_fields_schema(), - ) - ); - } - - /** - * Register the create post type ability. - * - * @since 6.6.0 - */ - private function register_create_post_type_ability() { - $input_schema = $this->get_post_type_schema(); - - wp_register_ability( - 'scf/create-post-type', - array( - 'label' => __( 'Create Post Type', 'secure-custom-fields' ), - 'description' => __( 'Creates a new custom post type in SCF with the provided configuration.', 'secure-custom-fields' ), - 'category' => 'scf-post-types', - 'execute_callback' => array( $this, 'create_post_type_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => $input_schema, - 'output_schema' => $this->get_post_type_with_internal_fields_schema(), - ) - ); - } - - /** - * Register the update post type ability. - * - * @since 6.6.0 - */ - private function register_update_post_type_ability() { - - // For updates, only ID is required, everything else is optional - $input_schema = $this->get_post_type_with_internal_fields_schema(); - $input_schema['required'] = array( 'ID' ); - - wp_register_ability( - 'scf/update-post-type', - array( - 'label' => __( 'Update Post Type', 'secure-custom-fields' ), - 'description' => __( 'Updates an existing SCF post type with new configuration.', 'secure-custom-fields' ), - 'category' => 'scf-post-types', - 'execute_callback' => array( $this, 'update_post_type_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => true, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => $input_schema, - 'output_schema' => $this->get_post_type_with_internal_fields_schema(), - ) - ); - } - - /** - * Register the delete post type ability. - * - * @since 6.6.0 - */ - private function register_delete_post_type_ability() { - wp_register_ability( - 'scf/delete-post-type', - array( - 'label' => __( 'Delete Post Type', 'secure-custom-fields' ), - 'description' => __( 'Permanently deletes an SCF post type. This action cannot be undone.', 'secure-custom-fields' ), - 'category' => 'scf-post-types', - 'execute_callback' => array( $this, 'delete_post_type_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => false, - 'destructive' => true, - 'idempotent' => true, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'identifier' => $this->get_scf_identifier_schema(), - ), - 'required' => array( 'identifier' ), - ), - 'output_schema' => array( - 'type' => 'boolean', - 'description' => __( 'True if post type was successfully deleted.', 'secure-custom-fields' ), - ), - ) - ); - } - - /** - * Register the duplicate post type ability. - * - * @since 6.6.0 - */ - private function register_duplicate_post_type_ability() { - wp_register_ability( - 'scf/duplicate-post-type', - array( - 'label' => __( 'Duplicate Post Type', 'secure-custom-fields' ), - 'description' => __( 'Creates a copy of an existing SCF post type. The duplicate receives a new unique key but retains the same post_type slug, so it will not register until the slug is changed.', 'secure-custom-fields' ), - 'category' => 'scf-post-types', - 'execute_callback' => array( $this, 'duplicate_post_type_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'identifier' => $this->get_scf_identifier_schema(), - 'new_post_id' => array( - 'type' => 'integer', - 'description' => __( 'Optional new post ID for the duplicated post type.', 'secure-custom-fields' ), - ), - ), - 'required' => array( 'identifier' ), - ), - 'output_schema' => $this->get_post_type_with_internal_fields_schema(), - ) - ); - } - - /** - * Register the export post type ability. - * - * @since 6.6.0 - */ - private function register_export_post_type_ability() { - wp_register_ability( - 'scf/export-post-type', - array( - 'label' => __( 'Export Post Type', 'secure-custom-fields' ), - 'description' => __( 'Exports an SCF post type configuration as JSON for backup or transfer.', 'secure-custom-fields' ), - 'category' => 'scf-post-types', - 'execute_callback' => array( $this, 'export_post_type_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => true, - 'destructive' => false, - 'idempotent' => true, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'identifier' => $this->get_scf_identifier_schema(), - ), - 'required' => array( 'identifier' ), - ), - 'output_schema' => $this->get_post_type_schema(), - ) - ); - } - - /** - * Register the import post type ability. - * - * @since 6.6.0 - */ - private function register_import_post_type_ability() { - wp_register_ability( - 'scf/import-post-type', - array( - 'label' => __( 'Import Post Type', 'secure-custom-fields' ), - 'description' => __( 'Imports an SCF post type from JSON configuration data.', 'secure-custom-fields' ), - 'category' => 'scf-post-types', - 'execute_callback' => array( $this, 'import_post_type_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => $this->get_post_type_with_internal_fields_schema(), - 'output_schema' => $this->get_post_type_with_internal_fields_schema(), - ) - ); - } - - /** - * Callback for the list post types ability. - * - * @since 6.6.0 - * - * @param array $input The input parameters. - * @return array The response data. - */ - public function list_post_types_callback( $input ) { - $filter = isset( $input['filter'] ) ? $input['filter'] : array(); - - $post_types = acf_get_acf_post_types( $filter ); - return is_array( $post_types ) ? $post_types : array(); - } - - /** - * Callback for the get post type ability. - * - * @since 6.6.0 - * - * @param array $input The input parameters. - * @return array The response data. - */ - public function get_post_type_callback( $input ) { - $post_type = acf_get_post_type( $input['identifier'] ); - - if ( ! $post_type ) { - return $this->post_type_not_found_error(); - } - - return $post_type; - } - - /** - * Callback for the create post type ability. - * - * @since 6.6.0 - * - * @param array $input The input parameters. - * @return array The response data. - */ - public function create_post_type_callback( $input ) { - // Check if post type already exists. - if ( acf_get_post_type( $input['key'] ) ) { - return new WP_Error( 'post_type_exists', __( 'A post type with this key already exists.', 'secure-custom-fields' ) ); - } - - $post_type = acf_update_post_type( $input ); - - if ( ! $post_type ) { - return new WP_Error( 'create_post_type_failed', __( 'Failed to create post type.', 'secure-custom-fields' ) ); - } - - return $post_type; - } - - /** - * Callback for the update post type ability. - * - * @since 6.6.0 - * - * @param array $input The input parameters. - * @return array|WP_Error The post type data on success, WP_Error on failure. - */ - public function update_post_type_callback( $input ) { - $existing_post_type = acf_get_post_type( $input['ID'] ); - if ( ! $existing_post_type ) { - return $this->post_type_not_found_error(); - } - - // Merge input with existing post type data to preserve unmodified fields. - $input = array_merge( $existing_post_type, $input ); - - $post_type = acf_update_post_type( $input ); - - if ( ! $post_type ) { - return new WP_Error( 'update_post_type_failed', __( 'Failed to update post type.', 'secure-custom-fields' ) ); - } - - return $post_type; - } - - /** - * Callback for the delete post type ability. - * - * @since 6.6.0 - * - * @param array $input The input parameters. - * @return bool|WP_Error True on success, WP_Error on failure. - */ - public function delete_post_type_callback( $input ) { - $post_type = acf_get_post_type( $input['identifier'] ); - if ( ! $post_type ) { - return $this->post_type_not_found_error(); - } - - $result = acf_delete_post_type( $input['identifier'] ); - - if ( ! $result ) { - return new WP_Error( 'delete_post_type_failed', __( 'Failed to delete post type.', 'secure-custom-fields' ) ); - } - - return true; - } - - /** - * Callback for the duplicate post type ability. - * - * @since 6.6.0 - * - * @param array $input The input parameters. - * @return array|WP_Error The duplicated post type data on success, WP_Error on failure. - */ - public function duplicate_post_type_callback( $input ) { - $post_type = acf_get_post_type( $input['identifier'] ); - if ( ! $post_type ) { - return $this->post_type_not_found_error(); - } - - $new_post_id = isset( $input['new_post_id'] ) ? $input['new_post_id'] : 0; - $duplicated_post_type = acf_duplicate_post_type( $input['identifier'], $new_post_id ); - - if ( ! $duplicated_post_type ) { - return new WP_Error( 'duplicate_post_type_failed', __( 'Failed to duplicate post type.', 'secure-custom-fields' ) ); - } - - return $duplicated_post_type; - } - - /** - * Callback for the export post type ability. - * - * @since 6.6.0 - * - * @param array $input The input parameters. - * @return array|WP_Error The export data on success, WP_Error on failure. - */ - public function export_post_type_callback( $input ) { - $post_type = acf_get_post_type( $input['identifier'] ); - if ( ! $post_type ) { - return $this->post_type_not_found_error(); - } - - $export_data = acf_prepare_internal_post_type_for_export( $post_type, 'acf-post-type' ); - - if ( ! $export_data ) { - return new WP_Error( 'export_post_type_failed', __( 'Failed to prepare post type for export.', 'secure-custom-fields' ) ); - } - - return $export_data; - } - - /** - * Callback for the import post type ability. - * - * @since 6.6.0 - * - * @param array|object $input The input parameters. - * @return array|WP_Error The imported post type data on success, WP_Error on failure. - */ - public function import_post_type_callback( $input ) { - // Import the post type (handles both create and update based on presence of ID). - $imported_post_type = acf_import_internal_post_type( $input, 'acf-post-type' ); - - if ( ! $imported_post_type ) { - return new WP_Error( 'import_post_type_failed', __( 'Failed to import post type.', 'secure-custom-fields' ) ); - } - - return $imported_post_type; - } - - /** - * Returns a WP_Error for post type not found. - * - * @return WP_Error The error object with 404 status. - */ - private function post_type_not_found_error() { - return new WP_Error( - 'post_type_not_found', - __( 'Post type not found.', 'secure-custom-fields' ), - array( 'status' => 404 ) - ); - } + protected $internal_post_type = 'acf-post-type'; } // Initialize abilities instance. acf_new_instance( 'SCF_Post_Type_Abilities' ); - -endif; // class_exists check +endif; // class_exists check. diff --git a/includes/abilities/class-scf-taxonomy-abilities.php b/includes/abilities/class-scf-taxonomy-abilities.php index a622167d..764a52e3 100644 --- a/includes/abilities/class-scf-taxonomy-abilities.php +++ b/includes/abilities/class-scf-taxonomy-abilities.php @@ -24,623 +24,17 @@ * * @since 6.7.0 */ - class SCF_Taxonomy_Abilities { + class SCF_Taxonomy_Abilities extends SCF_Internal_Post_Type_Abilities { /** - * Taxonomy schema to reuse across ability registrations. + * The internal post type identifier. * - * @var array|null + * @var string */ - private $taxonomy_schema = null; - - /** - * SCF identifier schema to reuse across ability registrations. - * - * @var array|null - */ - private $scf_identifier_schema = null; - - /** - * Constructor. - * - * @since 6.7.0 - */ - public function __construct() { - $validator = acf_get_instance( 'SCF_JSON_Schema_Validator' ); - - // Only register abilities if schemas are available. - if ( ! $validator->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' ) ); - } - - /** - * Get the SCF taxonomy schema, loading it once and caching for reuse. - * - * @since 6.7.0 - * @return array The taxonomy schema definition. - */ - private function get_taxonomy_schema() { - if ( null === $this->taxonomy_schema ) { - $validator = new SCF_JSON_Schema_Validator(); - $schema = $validator->load_schema( 'taxonomy' ); - - $this->taxonomy_schema = json_decode( wp_json_encode( $schema->definitions->taxonomy ), true ); - } - - return $this->taxonomy_schema; - } - - /** - * Get the SCF identifier schema, loading it once and caching for reuse. - * - * @since 6.7.0 - * - * @return array The SCF identifier schema definition. - */ - private function get_scf_identifier_schema() { - if ( null === $this->scf_identifier_schema ) { - $validator = new SCF_JSON_Schema_Validator(); - - $this->scf_identifier_schema = json_decode( wp_json_encode( $validator->load_schema( 'scf-identifier' ) ), true ); - } - - return $this->scf_identifier_schema; - } - - /** - * Get the internal fields schema (ID, _valid, local). - * - * @since 6.7.0 - * @return array The internal fields schema. - */ - 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->internalFields ), true ); - } - - /** - * Get the taxonomy schema extended with internal fields for GET/LIST/CREATE/UPDATE/IMPORT/DUPLICATE operations. - * - * @since 6.7.0 - * - * @return array The extended taxonomy schema with internal fields. - */ - private function get_taxonomy_with_internal_fields_schema() { - $schema = $this->get_taxonomy_schema(); - $internal_fields = $this->get_internal_fields_schema(); - $schema['properties'] = array_merge( $schema['properties'], $internal_fields['properties'] ); - - return $schema; - } - - /** - * Register SCF ability categories. - * - * @since 6.7.0 - */ - public function register_categories() { - wp_register_ability_category( - 'scf-taxonomies', - array( - 'label' => __( 'SCF Taxonomies', 'secure-custom-fields' ), - 'description' => __( 'Abilities for managing Secure Custom Fields taxonomies.', 'secure-custom-fields' ), - ) - ); - } - - /** - * Register all taxonomy abilities. - * - * @since 6.7.0 - */ - public function register_abilities() { - $this->register_list_taxonomies_ability(); - $this->register_get_taxonomy_ability(); - $this->register_create_taxonomy_ability(); - $this->register_update_taxonomy_ability(); - $this->register_delete_taxonomy_ability(); - $this->register_duplicate_taxonomy_ability(); - $this->register_export_taxonomy_ability(); - $this->register_import_taxonomy_ability(); - } - - /** - * Register the list taxonomies ability. - * - * @since 6.7.0 - */ - private function register_list_taxonomies_ability() { - wp_register_ability( - 'scf/list-taxonomies', - array( - 'label' => __( 'List Taxonomies', 'secure-custom-fields' ), - 'description' => __( 'Retrieves a list of all SCF taxonomies with optional filtering.', 'secure-custom-fields' ), - 'category' => 'scf-taxonomies', - 'execute_callback' => array( $this, 'list_taxonomies_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => true, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'filter' => array( - 'type' => 'object', - 'description' => __( 'Optional filters to apply to the taxonomy list.', 'secure-custom-fields' ), - 'properties' => array( - 'active' => array( - 'type' => 'boolean', - 'description' => __( 'Filter by active status.', 'secure-custom-fields' ), - ), - ), - ), - ), - ), - 'output_schema' => array( - 'type' => 'array', - 'items' => $this->get_taxonomy_with_internal_fields_schema(), - ), - ) - ); - } - - /** - * Register the get taxonomy ability. - * - * @since 6.7.0 - */ - private function register_get_taxonomy_ability() { - wp_register_ability( - 'scf/get-taxonomy', - array( - 'label' => __( 'Get Taxonomy', 'secure-custom-fields' ), - 'description' => __( 'Retrieves a specific SCF taxonomy configuration by ID or key.', 'secure-custom-fields' ), - 'category' => 'scf-taxonomies', - 'execute_callback' => array( $this, 'get_taxonomy_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => true, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'identifier' => $this->get_scf_identifier_schema(), - ), - 'required' => array( 'identifier' ), - ), - 'output_schema' => $this->get_taxonomy_with_internal_fields_schema(), - ) - ); - } - - /** - * Register the create taxonomy ability. - * - * @since 6.7.0 - */ - private function register_create_taxonomy_ability() { - $input_schema = $this->get_taxonomy_schema(); - - wp_register_ability( - 'scf/create-taxonomy', - array( - 'label' => __( 'Create Taxonomy', 'secure-custom-fields' ), - 'description' => __( 'Creates a new custom taxonomy in SCF with the provided configuration.', 'secure-custom-fields' ), - 'category' => 'scf-taxonomies', - 'execute_callback' => array( $this, 'create_taxonomy_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => $input_schema, - 'output_schema' => $this->get_taxonomy_with_internal_fields_schema(), - ) - ); - } - - /** - * Register the update taxonomy ability. - * - * @since 6.7.0 - */ - private function register_update_taxonomy_ability() { - - // For updates, only ID is required, everything else is optional. - $input_schema = $this->get_taxonomy_with_internal_fields_schema(); - $input_schema['required'] = array( 'ID' ); - - wp_register_ability( - 'scf/update-taxonomy', - array( - 'label' => __( 'Update Taxonomy', 'secure-custom-fields' ), - 'description' => __( 'Updates an existing SCF taxonomy with new configuration.', 'secure-custom-fields' ), - 'category' => 'scf-taxonomies', - 'execute_callback' => array( $this, 'update_taxonomy_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => true, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => $input_schema, - 'output_schema' => $this->get_taxonomy_with_internal_fields_schema(), - ) - ); - } - - /** - * Register the delete taxonomy ability. - * - * @since 6.7.0 - */ - private function register_delete_taxonomy_ability() { - wp_register_ability( - 'scf/delete-taxonomy', - array( - 'label' => __( 'Delete Taxonomy', 'secure-custom-fields' ), - 'description' => __( 'Permanently deletes an SCF taxonomy. This action cannot be undone.', 'secure-custom-fields' ), - 'category' => 'scf-taxonomies', - 'execute_callback' => array( $this, 'delete_taxonomy_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => false, - 'destructive' => true, - 'idempotent' => true, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'identifier' => $this->get_scf_identifier_schema(), - ), - 'required' => array( 'identifier' ), - ), - 'output_schema' => array( - 'type' => 'boolean', - 'description' => __( 'True if taxonomy was successfully deleted.', 'secure-custom-fields' ), - ), - ) - ); - } - - /** - * Register the duplicate taxonomy ability. - * - * @since 6.7.0 - */ - private function register_duplicate_taxonomy_ability() { - wp_register_ability( - 'scf/duplicate-taxonomy', - array( - 'label' => __( 'Duplicate Taxonomy', 'secure-custom-fields' ), - 'description' => __( 'Creates a copy of an existing SCF taxonomy. The duplicate receives a new unique key but retains the same taxonomy slug, so it will not register until the slug is changed.', 'secure-custom-fields' ), - 'category' => 'scf-taxonomies', - 'execute_callback' => array( $this, 'duplicate_taxonomy_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'identifier' => $this->get_scf_identifier_schema(), - 'new_post_id' => array( - 'type' => 'integer', - 'description' => __( 'Optional new post ID for the duplicated taxonomy.', 'secure-custom-fields' ), - ), - ), - 'required' => array( 'identifier' ), - ), - 'output_schema' => $this->get_taxonomy_with_internal_fields_schema(), - ) - ); - } - - /** - * Register the export taxonomy ability. - * - * @since 6.7.0 - */ - private function register_export_taxonomy_ability() { - wp_register_ability( - 'scf/export-taxonomy', - array( - 'label' => __( 'Export Taxonomy', 'secure-custom-fields' ), - 'description' => __( 'Exports an SCF taxonomy configuration as JSON for backup or transfer.', 'secure-custom-fields' ), - 'category' => 'scf-taxonomies', - 'execute_callback' => array( $this, 'export_taxonomy_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => true, - 'destructive' => false, - 'idempotent' => true, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'identifier' => $this->get_scf_identifier_schema(), - ), - 'required' => array( 'identifier' ), - ), - 'output_schema' => $this->get_taxonomy_schema(), - ) - ); - } - - /** - * Register the import taxonomy ability. - * - * @since 6.7.0 - */ - private function register_import_taxonomy_ability() { - wp_register_ability( - 'scf/import-taxonomy', - array( - 'label' => __( 'Import Taxonomy', 'secure-custom-fields' ), - 'description' => __( 'Imports an SCF taxonomy from JSON configuration data.', 'secure-custom-fields' ), - 'category' => 'scf-taxonomies', - 'execute_callback' => array( $this, 'import_taxonomy_callback' ), - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - ), - 'annotations' => array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, - ), - ), - 'permission_callback' => 'scf_current_user_has_capability', - 'input_schema' => $this->get_taxonomy_with_internal_fields_schema(), - 'output_schema' => $this->get_taxonomy_with_internal_fields_schema(), - ) - ); - } - - /** - * Callback for the list taxonomies ability. - * - * @since 6.7.0 - * - * @param array $input The input parameters. - * @return array The response data. - */ - public function list_taxonomies_callback( $input ) { - $filter = isset( $input['filter'] ) ? $input['filter'] : array(); - - $taxonomies = acf_get_acf_taxonomies( $filter ); - return is_array( $taxonomies ) ? $taxonomies : array(); - } - - /** - * Callback for the get taxonomy ability. - * - * @since 6.7.0 - * - * @param array $input The input parameters. - * @return array|WP_Error The taxonomy data on success, WP_Error on failure. - */ - public function get_taxonomy_callback( $input ) { - $taxonomy = acf_get_taxonomy( $input['identifier'] ); - - if ( ! $taxonomy ) { - return $this->taxonomy_not_found_error(); - } - - return $taxonomy; - } - - /** - * Callback for the create taxonomy ability. - * - * @since 6.7.0 - * - * @param array $input The input parameters. - * @return array|WP_Error The taxonomy data on success, WP_Error on failure. - */ - public function create_taxonomy_callback( $input ) { - // Check if taxonomy already exists. - if ( acf_get_taxonomy( $input['key'] ) ) { - return new WP_Error( 'taxonomy_exists', __( 'A taxonomy with this key already exists.', 'secure-custom-fields' ) ); - } - - $taxonomy = acf_update_taxonomy( $input ); - - if ( ! $taxonomy ) { - return new WP_Error( 'create_taxonomy_failed', __( 'Failed to create taxonomy.', 'secure-custom-fields' ) ); - } - - return $taxonomy; - } - - /** - * Callback for the update taxonomy ability. - * - * @since 6.7.0 - * - * @param array $input The input parameters. - * @return array|WP_Error The taxonomy data on success, WP_Error on failure. - */ - public function update_taxonomy_callback( $input ) { - $existing_taxonomy = acf_get_taxonomy( $input['ID'] ); - if ( ! $existing_taxonomy ) { - return $this->taxonomy_not_found_error(); - } - - // Merge input with existing taxonomy data to preserve unmodified fields. - $input = array_merge( $existing_taxonomy, $input ); - - $taxonomy = acf_update_taxonomy( $input ); - - if ( ! $taxonomy ) { - return new WP_Error( 'update_taxonomy_failed', __( 'Failed to update taxonomy.', 'secure-custom-fields' ) ); - } - - return $taxonomy; - } - - /** - * Callback for the delete taxonomy ability. - * - * @since 6.7.0 - * - * @param array $input The input parameters. - * @return bool|WP_Error True on success, WP_Error on failure. - */ - public function delete_taxonomy_callback( $input ) { - $taxonomy = acf_get_taxonomy( $input['identifier'] ); - if ( ! $taxonomy ) { - return $this->taxonomy_not_found_error(); - } - - $result = acf_delete_taxonomy( $input['identifier'] ); - - if ( ! $result ) { - return new WP_Error( 'delete_taxonomy_failed', __( 'Failed to delete taxonomy.', 'secure-custom-fields' ) ); - } - - return true; - } - - /** - * Callback for the duplicate taxonomy ability. - * - * @since 6.7.0 - * - * @param array $input The input parameters. - * @return array|WP_Error The duplicated taxonomy data on success, WP_Error on failure. - */ - public function duplicate_taxonomy_callback( $input ) { - $taxonomy = acf_get_taxonomy( $input['identifier'] ); - if ( ! $taxonomy ) { - return $this->taxonomy_not_found_error(); - } - - $new_post_id = isset( $input['new_post_id'] ) ? $input['new_post_id'] : 0; - $duplicated_taxonomy = acf_duplicate_taxonomy( $input['identifier'], $new_post_id ); - - if ( ! $duplicated_taxonomy ) { - return new WP_Error( 'duplicate_taxonomy_failed', __( 'Failed to duplicate taxonomy.', 'secure-custom-fields' ) ); - } - - return $duplicated_taxonomy; - } - - /** - * Callback for the export taxonomy ability. - * - * @since 6.7.0 - * - * @param array $input The input parameters. - * @return array|WP_Error The export data on success, WP_Error on failure. - */ - public function export_taxonomy_callback( $input ) { - $taxonomy = acf_get_taxonomy( $input['identifier'] ); - if ( ! $taxonomy ) { - return $this->taxonomy_not_found_error(); - } - - $export_data = acf_prepare_internal_post_type_for_export( $taxonomy, 'acf-taxonomy' ); - - if ( ! $export_data ) { - return new WP_Error( 'export_taxonomy_failed', __( 'Failed to prepare taxonomy for export.', 'secure-custom-fields' ) ); - } - - return $export_data; - } - - /** - * Callback for the import taxonomy ability. - * - * @since 6.7.0 - * - * @param array|object $input The input parameters. - * @return array|WP_Error The imported taxonomy data on success, WP_Error on failure. - */ - public function import_taxonomy_callback( $input ) { - // Import the taxonomy (handles both create and update based on presence of ID). - $imported_taxonomy = acf_import_internal_post_type( $input, 'acf-taxonomy' ); - - if ( ! $imported_taxonomy ) { - return new WP_Error( 'import_taxonomy_failed', __( 'Failed to import taxonomy.', 'secure-custom-fields' ) ); - } - - return $imported_taxonomy; - } - - /** - * Returns a WP_Error for taxonomy not found. - * - * @since 6.7.0 - * @return WP_Error The error object with 404 status. - */ - private function taxonomy_not_found_error() { - return new WP_Error( - 'taxonomy_not_found', - __( 'Taxonomy not found.', 'secure-custom-fields' ), - array( 'status' => 404 ) - ); - } + protected $internal_post_type = 'acf-taxonomy'; } // Initialize abilities instance. acf_new_instance( 'SCF_Taxonomy_Abilities' ); - endif; // class_exists check. diff --git a/includes/class-scf-json-schema-validator.php b/includes/class-scf-json-schema-validator.php index d6ea7b93..4a66ee28 100644 --- a/includes/class-scf-json-schema-validator.php +++ b/includes/class-scf-json-schema-validator.php @@ -22,11 +22,11 @@ class SCF_JSON_Schema_Validator { /** - * Required schema files for post type abilities + * Required schema files for SCF abilities. * * @var array */ - public const REQUIRED_SCHEMAS = array( 'post-type', 'internal-fields', 'scf-identifier' ); + public const REQUIRED_SCHEMAS = array( 'post-type', 'taxonomy', 'internal-fields', 'scf-identifier' ); /** * The last validation errors. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 569560d5..c6c32f3a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -15,6 +15,7 @@ ./tests/php/ + ./tests/php/ diff --git a/secure-custom-fields.php b/secure-custom-fields.php index ae8e1630..09b0c659 100644 --- a/secure-custom-fields.php +++ b/secure-custom-fields.php @@ -190,7 +190,7 @@ public function initialize() { acf_include( 'includes/class-acf-options-page.php' ); acf_include( 'includes/class-acf-site-health.php' ); acf_include( 'includes/class-scf-json-schema-validator.php' ); - acf_include( 'includes/abilities/abilities-integration.php' ); + acf_include( 'includes/abilities/class-scf-abilities-integration.php' ); acf_include( 'includes/fields/class-acf-field.php' ); acf_include( 'includes/locations/abstract-acf-legacy-location.php' ); acf_include( 'includes/locations/abstract-acf-location.php' ); diff --git a/tests/e2e/abilities-internal-post-types.spec.ts b/tests/e2e/abilities-internal-post-types.spec.ts new file mode 100644 index 00000000..70599f30 --- /dev/null +++ b/tests/e2e/abilities-internal-post-types.spec.ts @@ -0,0 +1,510 @@ +/** + * E2E tests for SCF Internal Post Type Abilities (Post Types and Taxonomies) + * + * Tests the WordPress Abilities API endpoints for SCF post type and taxonomy management. + * Both entity types share the same base class, so tests are parameterized. + * + * HTTP Method Reference: + * - Read-only abilities (readonly: true) → GET with bracket notation: { 'input[key]': value } + * - Regular abilities (readonly: false, destructive: false) → POST with JSON body: { input: { key: value } } + * - Destructive abilities (destructive: true) → DELETE with bracket notation: { 'input[key]': value } + * + * Note: GET/DELETE use bracket notation because PHP parses `?input[key]=val` into arrays. + * JSON.stringify does NOT work for query params - PHP receives a string, not an object. + */ +const { test, expect } = require( './fixtures' ); + +const PLUGIN_SLUG = 'secure-custom-fields'; +const ABILITIES_BASE = '/wp-abilities/v1/abilities'; + +/** + * Entity type configurations for parameterized tests. + */ +const ENTITY_TYPES = [ + { + name: 'Post Type', + slug: 'post-type', + slugPlural: 'post-types', + identifierKey: 'post_type', + testEntity: { + key: 'post_type_e2e_test', + title: 'E2E Test Type', + post_type: 'e2e_test', + }, + }, + { + name: 'Taxonomy', + slug: 'taxonomy', + slugPlural: 'taxonomies', + identifierKey: 'taxonomy', + testEntity: { + key: 'taxonomy_e2e_test', + title: 'E2E Test Taxonomy', + taxonomy: 'e2e_test_tax', + }, + }, +]; + +// Shared 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 ); + } +} + +/** + * Creates API helper functions for a given entity type. + * + * @param {Object} entityType - The entity type configuration. + * @return {Object} Object containing API helper functions. + */ +function createApiHelpers( entityType ) { + const { slug, slugPlural, testEntity } = entityType; + + return { + 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-${ slugPlural }/run`, + params, + } ); + }, + + get: ( requestUtils, identifier ) => + requestUtils.rest( { + method: 'GET', + path: `${ ABILITIES_BASE }/scf/get-${ slug }/run`, + params: { 'input[identifier]': identifier }, + } ), + + create: ( requestUtils, data = testEntity ) => + requestUtils.rest( { + method: 'POST', + path: `${ ABILITIES_BASE }/scf/create-${ slug }/run`, + data: { input: data }, + } ), + + update: ( requestUtils, input ) => + requestUtils.rest( { + method: 'POST', + path: `${ ABILITIES_BASE }/scf/update-${ slug }/run`, + data: { input }, + } ), + + delete: ( requestUtils, identifier ) => + requestUtils.rest( { + method: 'DELETE', + path: `${ ABILITIES_BASE }/scf/delete-${ slug }/run`, + params: { 'input[identifier]': identifier }, + } ), + + duplicate: ( requestUtils, identifier ) => + requestUtils.rest( { + method: 'POST', + path: `${ ABILITIES_BASE }/scf/duplicate-${ slug }/run`, + data: { input: { identifier } }, + } ), + + export: ( requestUtils, identifier ) => + requestUtils.rest( { + method: 'GET', + path: `${ ABILITIES_BASE }/scf/export-${ slug }/run`, + params: { 'input[identifier]': identifier }, + } ), + + import: ( requestUtils, data ) => + requestUtils.rest( { + method: 'POST', + path: `${ ABILITIES_BASE }/scf/import-${ slug }/run`, + data: { input: data }, + } ), + + cleanup: async ( requestUtils, identifier ) => { + try { + await requestUtils.rest( { + method: 'DELETE', + path: `${ ABILITIES_BASE }/scf/delete-${ slug }/run`, + params: { 'input[identifier]': identifier }, + } ); + } catch { + // Ignore errors - entity may not exist + } + }, + }; +} + +// Run tests for each entity type +for ( const entityType of ENTITY_TYPES ) { + const { name, slug, slugPlural, identifierKey, testEntity } = entityType; + const api = createApiHelpers( entityType ); + + test.describe( `${ name } Abilities`, () => { + 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' + ); + } ); + + // List entities - GET with query params (readonly) + + test.describe( `scf/list-${ slugPlural }`, () => { + test.beforeEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + await api.create( requestUtils ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + } ); + + test( `should list all SCF ${ slugPlural }`, async ( { + requestUtils, + } ) => { + const result = await api.list( requestUtils ); + + expect( Array.isArray( result ) ).toBe( true ); + expect( + result.some( ( item ) => item.key === testEntity.key ) + ).toBe( true ); + } ); + + test( 'should support filter parameter', async ( { + requestUtils, + } ) => { + const result = await api.list( requestUtils, { active: true } ); + + expect( Array.isArray( result ) ).toBe( true ); + } ); + } ); + + // Get entity - GET with query params (readonly) + + test.describe( `scf/get-${ slug }`, () => { + test.beforeEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + await api.create( requestUtils ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + } ); + + test( `should get a ${ slug } by identifier`, async ( { + requestUtils, + } ) => { + const result = await api.get( requestUtils, testEntity.key ); + + expect( result ).toHaveProperty( + identifierKey, + testEntity[ identifierKey ] + ); + expect( result ).toHaveProperty( 'title', testEntity.title ); + } ); + + test( `should return error for non-existent ${ slug }`, async ( { + requestUtils, + } ) => { + await expectNotFound( + api.get( requestUtils, 'nonexistent_entity_abc' ) + ); + } ); + } ); + + // Export entity - GET with query params (readonly) + + test.describe( `scf/export-${ slug }`, () => { + test.beforeEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + await api.create( requestUtils ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + } ); + + test( `should export a ${ slug } as JSON`, async ( { + requestUtils, + } ) => { + const result = await api.export( requestUtils, testEntity.key ); + + expect( result ).toHaveProperty( 'key', testEntity.key ); + expect( result ).toHaveProperty( + identifierKey, + testEntity[ identifierKey ] + ); + expect( result ).toHaveProperty( 'title', testEntity.title ); + } ); + + test( `should return error for non-existent ${ slug }`, async ( { + requestUtils, + } ) => { + await expectNotFound( + api.export( requestUtils, 'nonexistent_export' ) + ); + } ); + } ); + + // Create entity - POST with body + + test.describe( `scf/create-${ slug }`, () => { + test.beforeEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + } ); + + test( `should create a new ${ slug }`, async ( { + requestUtils, + } ) => { + const result = await api.create( requestUtils ); + + expect( result ).toHaveProperty( + identifierKey, + testEntity[ identifierKey ] + ); + expect( result ).toHaveProperty( 'title', testEntity.title ); + expect( result ).toHaveProperty( 'key', testEntity.key ); + } ); + + test( 'should return error when required fields are missing', async ( { + requestUtils, + } ) => { + await expectInvalidInput( + api.create( requestUtils, { + title: 'Missing Required Fields', + } ) + ); + } ); + } ); + + // Update entity - POST with body + + test.describe( `scf/update-${ slug }`, () => { + let testEntityId; + + test.beforeEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + const result = await api.create( requestUtils ); + testEntityId = result.ID; + } ); + + test.afterEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + } ); + + test( `should update an existing ${ slug }`, async ( { + requestUtils, + } ) => { + const result = await api.update( requestUtils, { + ID: testEntityId, + title: 'Updated Title', + } ); + + expect( result ).toHaveProperty( 'title', 'Updated Title' ); + } ); + + test( `should return error for non-existent ${ slug } ID`, async ( { + requestUtils, + } ) => { + await expectNotFound( + api.update( requestUtils, { + ID: 999999, + title: 'Should Fail', + } ) + ); + } ); + + test( 'should return error when ID is missing', async ( { + requestUtils, + } ) => { + await expectInvalidInput( + api.update( requestUtils, { title: 'Missing ID' } ) + ); + } ); + } ); + + // Delete entity - DELETE with query params (destructive) + + test.describe( `scf/delete-${ slug }`, () => { + test.beforeEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + await api.create( requestUtils ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + } ); + + test( `should delete an existing ${ slug }`, async ( { + requestUtils, + } ) => { + const result = await api.delete( requestUtils, testEntity.key ); + expect( result ).toBe( true ); + + // Verify it's actually deleted + await expectNotFound( api.get( requestUtils, testEntity.key ) ); + } ); + + test( `should return error for non-existent ${ slug }`, async ( { + requestUtils, + } ) => { + await api.cleanup( requestUtils, testEntity.key ); + + await expectNotFound( + api.delete( requestUtils, 'nonexistent_entity_xyz' ) + ); + } ); + + test( 'should return error when identifier is missing', async ( { + requestUtils, + } ) => { + await expectInvalidInput( + requestUtils.rest( { + method: 'DELETE', + path: `${ ABILITIES_BASE }/scf/delete-${ slug }/run`, + params: { input: '' }, + } ) + ); + } ); + } ); + + // Duplicate entity - POST with body + // + // Note: The duplicate receives a new unique key but retains the same + // slug. The duplicate won't register until slug is changed. + + test.describe( `scf/duplicate-${ slug }`, () => { + let duplicatedKey; + + test.beforeEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + await api.create( requestUtils ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + if ( duplicatedKey ) { + await api.cleanup( requestUtils, duplicatedKey ); + duplicatedKey = null; + } + } ); + + test( `should duplicate an existing ${ slug }`, async ( { + requestUtils, + } ) => { + const result = await api.duplicate( + requestUtils, + testEntity.key + ); + duplicatedKey = result.key; + + expect( result ).toHaveProperty( 'key' ); + expect( result ).toHaveProperty( identifierKey ); + expect( result.key ).not.toBe( testEntity.key ); + expect( result[ identifierKey ] ).toBe( + testEntity[ identifierKey ] + ); + expect( result.title ).toContain( '(copy)' ); + } ); + + test( `should return error for non-existent ${ slug }`, async ( { + requestUtils, + } ) => { + await expectNotFound( + api.duplicate( + requestUtils, + 'nonexistent_duplicate_source' + ) + ); + } ); + } ); + + // Import entity - POST with body + + test.describe( `scf/import-${ slug }`, () => { + test.beforeEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await api.cleanup( requestUtils, testEntity.key ); + } ); + + test( `should import a ${ slug } from JSON`, async ( { + requestUtils, + } ) => { + const result = await api.import( requestUtils, testEntity ); + + expect( result ).toHaveProperty( + identifierKey, + testEntity[ identifierKey ] + ); + expect( result ).toHaveProperty( 'title', testEntity.title ); + } ); + + test( 'should return error when required fields are missing', async ( { + requestUtils, + } ) => { + await expectInvalidInput( + api.import( requestUtils, { + title: 'Missing Required Fields', + } ) + ); + } ); + } ); + } ); +} diff --git a/tests/e2e/abilities-post-types.spec.ts b/tests/e2e/abilities-post-types.spec.ts deleted file mode 100644 index 13b13b37..00000000 --- a/tests/e2e/abilities-post-types.spec.ts +++ /dev/null @@ -1,385 +0,0 @@ -/** - * E2E tests for SCF Post Type Abilities - * - * Tests the WordPress Abilities API endpoints for SCF post type management. - * - * HTTP Method Reference (per PR #152 in WordPress/abilities-api): - * - Read-only abilities (readonly: true) → GET with query params - * - Regular abilities (readonly: false, destructive: false) → POST with body - * - Destructive abilities (destructive: true) → DELETE with query params - */ -const { test, expect } = require( './fixtures' ); - -test.describe( 'Post Type Abilities', () => { - const PLUGIN_SLUG = 'secure-custom-fields'; - const ABILITIES_BASE = '/wp-abilities/v1/abilities'; - - // Reusable test post type - each test creates/cleans its own instance - const TEST_POST_TYPE = { - key: 'post_type_e2e_test', - title: 'E2E Test Type', - post_type: 'e2e_test', - }; - - // Helper functions - - /** - * Check if Abilities API exists (for older WordPress versions). - */ - 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. - */ - async function expectNotFound( requestPromise ) { - try { - await requestPromise; - throw new Error( 'Expected not found error but request succeeded' ); - } catch ( error ) { - expect( error.code ).toBe( 'post_type_not_found' ); - expect( error.data?.status ).toBe( 404 ); - } - } - - /** - * Assert that a REST request throws an "invalid input" error with 400 status. - */ - 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 ); - } - } - - // Post type API helpers - - async function listPostTypes( requestUtils, filter = {} ) { - return await requestUtils.rest( { - method: 'POST', - path: `${ ABILITIES_BASE }/scf/list-post-types/run`, - data: { input: { filter } }, - } ); - } - - async function getPostType( requestUtils, identifier ) { - return await requestUtils.rest( { - method: 'POST', - path: `${ ABILITIES_BASE }/scf/get-post-type/run`, - data: { input: { identifier } }, - } ); - } - - async function createPostType( requestUtils, postTypeData = TEST_POST_TYPE ) { - return await requestUtils.rest( { - method: 'POST', - path: `${ ABILITIES_BASE }/scf/create-post-type/run`, - data: { input: postTypeData }, - } ); - } - - async function updatePostType( requestUtils, input ) { - return await requestUtils.rest( { - method: 'POST', - path: `${ ABILITIES_BASE }/scf/update-post-type/run`, - data: { input }, - } ); - } - - async function deletePostType( requestUtils, identifier ) { - return await requestUtils.rest( { - method: 'DELETE', - path: `${ ABILITIES_BASE }/scf/delete-post-type/run`, - params: { 'input[identifier]': identifier }, - } ); - } - - async function duplicatePostType( requestUtils, identifier ) { - return await requestUtils.rest( { - method: 'POST', - path: `${ ABILITIES_BASE }/scf/duplicate-post-type/run`, - data: { input: { identifier } }, - } ); - } - - async function exportPostType( requestUtils, identifier ) { - return await requestUtils.rest( { - method: 'GET', - path: `${ ABILITIES_BASE }/scf/export-post-type/run`, - params: { 'input[identifier]': identifier }, - } ); - } - - async function importPostType( requestUtils, postTypeData ) { - return await requestUtils.rest( { - method: 'POST', - path: `${ ABILITIES_BASE }/scf/import-post-type/run`, - data: { input: postTypeData }, - } ); - } - - /** - * Clean up a post type (ignore errors if it doesn't exist). - */ - async function cleanupPostType( requestUtils, identifier ) { - try { - await deletePostType( requestUtils, identifier ); - } catch { - // Ignore errors - post type may not exist - } - } - - // Test setup - - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activatePlugin( PLUGIN_SLUG ); - - // Skip all tests if Abilities API is not available (older WordPress versions) - const hasAbilitiesApi = await abilitiesApiExists( requestUtils ); - test.skip( - ! hasAbilitiesApi, - 'Abilities API not available in this WordPress version' - ); - } ); - - // List post types - POST with body - - test.describe( 'scf/list-post-types', () => { - test.beforeEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - await createPostType( requestUtils ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - } ); - - test( 'should list all SCF post types', async ( { requestUtils } ) => { - const result = await listPostTypes( requestUtils ); - - expect( Array.isArray( result ) ).toBe( true ); - expect( result.some( ( pt ) => pt.key === TEST_POST_TYPE.key ) ).toBe( true ); - } ); - - test( 'should support filter parameter', async ( { requestUtils } ) => { - const result = await listPostTypes( requestUtils, { active: true } ); - - expect( Array.isArray( result ) ).toBe( true ); - } ); - } ); - - // Get post type - POST with body - - test.describe( 'scf/get-post-type', () => { - test.beforeEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - await createPostType( requestUtils ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - } ); - - test( 'should get a post type by identifier', async ( { requestUtils } ) => { - const result = await getPostType( requestUtils, TEST_POST_TYPE.key ); - - expect( result ).toHaveProperty( 'post_type', TEST_POST_TYPE.post_type ); - expect( result ).toHaveProperty( 'title', TEST_POST_TYPE.title ); - } ); - - test( 'should return error for non-existent post type', async ( { requestUtils } ) => { - await expectNotFound( getPostType( requestUtils, 'nonexistent_type_abc' ) ); - } ); - } ); - - // Export post type - GET with query params (readonly) - - test.describe( 'scf/export-post-type', () => { - test.beforeEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - await createPostType( requestUtils ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - } ); - - test( 'should export a post type as JSON', async ( { requestUtils } ) => { - const result = await exportPostType( requestUtils, TEST_POST_TYPE.key ); - - expect( result ).toHaveProperty( 'key', TEST_POST_TYPE.key ); - expect( result ).toHaveProperty( 'post_type', TEST_POST_TYPE.post_type ); - expect( result ).toHaveProperty( 'title', TEST_POST_TYPE.title ); - } ); - - test( 'should return error for non-existent post type', async ( { requestUtils } ) => { - await expectNotFound( exportPostType( requestUtils, 'nonexistent_export' ) ); - } ); - } ); - - // Create post type - POST with body - - test.describe( 'scf/create-post-type', () => { - test.beforeEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - } ); - - test( 'should create a new post type', async ( { requestUtils } ) => { - const result = await createPostType( requestUtils ); - - expect( result ).toHaveProperty( 'post_type', TEST_POST_TYPE.post_type ); - expect( result ).toHaveProperty( 'title', TEST_POST_TYPE.title ); - expect( result ).toHaveProperty( 'key', TEST_POST_TYPE.key ); - } ); - - test( 'should return error when required fields are missing', async ( { requestUtils } ) => { - await expectInvalidInput( createPostType( requestUtils, { title: 'Missing Key and Post Type' } ) ); - } ); - } ); - - // Update post type - POST with body - - test.describe( 'scf/update-post-type', () => { - let testPostTypeId; - - test.beforeEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - const result = await createPostType( requestUtils ); - testPostTypeId = result.ID; - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - } ); - - test( 'should update an existing post type', async ( { requestUtils } ) => { - const result = await updatePostType( requestUtils, { - ID: testPostTypeId, - title: 'Updated Title', - } ); - - expect( result ).toHaveProperty( 'title', 'Updated Title' ); - } ); - - test( 'should return error for non-existent post type ID', async ( { requestUtils } ) => { - await expectNotFound( updatePostType( requestUtils, { ID: 999999, title: 'Should Fail' } ) ); - } ); - - test( 'should return error when ID is missing', async ( { requestUtils } ) => { - await expectInvalidInput( updatePostType( requestUtils, { title: 'Missing ID' } ) ); - } ); - } ); - - // Delete post type - DELETE with query params (destructive) - - test.describe( 'scf/delete-post-type', () => { - test.beforeEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - await createPostType( requestUtils ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - } ); - - test( 'should delete an existing post type', async ( { requestUtils } ) => { - const result = await deletePostType( requestUtils, TEST_POST_TYPE.key ); - expect( result ).toBe( true ); - - // Verify it's actually deleted - await expectNotFound( getPostType( requestUtils, TEST_POST_TYPE.key ) ); - } ); - - test( 'should return error for non-existent post type', async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - - await expectNotFound( deletePostType( requestUtils, 'nonexistent_type_xyz' ) ); - } ); - - test( 'should return error when identifier is missing', async ( { requestUtils } ) => { - await expectInvalidInput( - requestUtils.rest( { - method: 'DELETE', - path: `${ ABILITIES_BASE }/scf/delete-post-type/run`, - params: { input: '' }, - } ) - ); - } ); - } ); - - // Duplicate post type - POST with body - // - // Note: The duplicate receives a new unique key but retains the same - // post_type slug. The duplicate won't register until slug is changed. - - test.describe( 'scf/duplicate-post-type', () => { - let duplicatedKey; - - test.beforeEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - await createPostType( requestUtils ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - if ( duplicatedKey ) { - await cleanupPostType( requestUtils, duplicatedKey ); - duplicatedKey = null; - } - } ); - - test( 'should duplicate an existing post type', async ( { requestUtils } ) => { - const result = await duplicatePostType( requestUtils, TEST_POST_TYPE.key ); - duplicatedKey = result.key; - - expect( result ).toHaveProperty( 'key' ); - expect( result ).toHaveProperty( 'post_type' ); - expect( result.key ).not.toBe( TEST_POST_TYPE.key ); - expect( result.post_type ).toBe( TEST_POST_TYPE.post_type ); - expect( result.title ).toContain( '(copy)' ); - } ); - - test( 'should return error for non-existent post type', async ( { requestUtils } ) => { - await expectNotFound( duplicatePostType( requestUtils, 'nonexistent_duplicate_source' ) ); - } ); - } ); - - // Import post type - POST with body - - test.describe( 'scf/import-post-type', () => { - test.beforeEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupPostType( requestUtils, TEST_POST_TYPE.key ); - } ); - - test( 'should import a post type from JSON', async ( { requestUtils } ) => { - const result = await importPostType( requestUtils, TEST_POST_TYPE ); - - expect( result ).toHaveProperty( 'post_type', TEST_POST_TYPE.post_type ); - expect( result ).toHaveProperty( 'title', TEST_POST_TYPE.title ); - } ); - - test( 'should return error when required fields are missing', async ( { requestUtils } ) => { - await expectInvalidInput( importPostType( requestUtils, { title: 'Missing Required Fields' } ) ); - } ); - } ); -} ); diff --git a/tests/e2e/abilities-taxonomies.spec.ts b/tests/e2e/abilities-taxonomies.spec.ts deleted file mode 100644 index 8452d2c3..00000000 --- a/tests/e2e/abilities-taxonomies.spec.ts +++ /dev/null @@ -1,482 +0,0 @@ -/** - * E2E tests for SCF Taxonomy Abilities - * - * Tests the WordPress Abilities API endpoints for SCF taxonomy management. - * - * HTTP Method Reference (per PR #152 in WordPress/abilities-api): - * - Read-only abilities (readonly: true) → GET with query params - * - Regular abilities (readonly: false, destructive: false) → POST with body - * - Destructive abilities (destructive: true) → DELETE with query params - */ -const { test, expect } = require( './fixtures' ); - -test.describe( 'Taxonomy Abilities', () => { - const PLUGIN_SLUG = 'secure-custom-fields'; - const ABILITIES_BASE = '/wp-abilities/v1/abilities'; - - // Reusable test taxonomy - each test creates/cleans its own instance - const TEST_TAXONOMY = { - key: 'taxonomy_e2e_test', - title: 'E2E Test Taxonomy', - taxonomy: 'e2e_test_tax', - }; - - // Helper functions - - /** - * Check if Abilities API exists (for older WordPress versions). - * - * @param {Object} requestUtils - Playwright request utilities. - */ - 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( 'taxonomy_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 ); - } - } - - // Taxonomy API helpers - - async function listTaxonomies( requestUtils, filter = {} ) { - return await requestUtils.rest( { - method: 'POST', - path: `${ ABILITIES_BASE }/scf/list-taxonomies/run`, - data: { input: { filter } }, - } ); - } - - async function getTaxonomy( requestUtils, identifier ) { - return await requestUtils.rest( { - method: 'POST', - path: `${ ABILITIES_BASE }/scf/get-taxonomy/run`, - data: { input: { identifier } }, - } ); - } - - async function createTaxonomy( - requestUtils, - taxonomyData = TEST_TAXONOMY - ) { - return await requestUtils.rest( { - method: 'POST', - path: `${ ABILITIES_BASE }/scf/create-taxonomy/run`, - data: { input: taxonomyData }, - } ); - } - - async function updateTaxonomy( requestUtils, input ) { - return await requestUtils.rest( { - method: 'POST', - path: `${ ABILITIES_BASE }/scf/update-taxonomy/run`, - data: { input }, - } ); - } - - async function deleteTaxonomy( requestUtils, identifier ) { - return await requestUtils.rest( { - method: 'DELETE', - path: `${ ABILITIES_BASE }/scf/delete-taxonomy/run`, - params: { 'input[identifier]': identifier }, - } ); - } - - async function duplicateTaxonomy( requestUtils, identifier ) { - return await requestUtils.rest( { - method: 'POST', - path: `${ ABILITIES_BASE }/scf/duplicate-taxonomy/run`, - data: { input: { identifier } }, - } ); - } - - async function exportTaxonomy( requestUtils, identifier ) { - return await requestUtils.rest( { - method: 'GET', - path: `${ ABILITIES_BASE }/scf/export-taxonomy/run`, - params: { 'input[identifier]': identifier }, - } ); - } - - async function importTaxonomy( requestUtils, taxonomyData ) { - return await requestUtils.rest( { - method: 'POST', - path: `${ ABILITIES_BASE }/scf/import-taxonomy/run`, - data: { input: taxonomyData }, - } ); - } - - /** - * Clean up a taxonomy (ignore errors if it doesn't exist). - * - * @param {Object} requestUtils - Playwright request utilities. - * @param {string} identifier - The taxonomy identifier (key or ID). - */ - async function cleanupTaxonomy( requestUtils, identifier ) { - try { - await deleteTaxonomy( requestUtils, identifier ); - } catch { - // Ignore errors - taxonomy may not exist - } - } - - // Test setup - - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activatePlugin( PLUGIN_SLUG ); - - // Skip all tests if Abilities API is not available (older WordPress versions) - const hasAbilitiesApi = await abilitiesApiExists( requestUtils ); - test.skip( - ! hasAbilitiesApi, - 'Abilities API not available in this WordPress version' - ); - } ); - - // List taxonomies - POST with body - - test.describe( 'scf/list-taxonomies', () => { - test.beforeEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - await createTaxonomy( requestUtils ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - } ); - - test( 'should list all SCF taxonomies', async ( { requestUtils } ) => { - const result = await listTaxonomies( requestUtils ); - - expect( Array.isArray( result ) ).toBe( true ); - expect( - result.some( ( tax ) => tax.key === TEST_TAXONOMY.key ) - ).toBe( true ); - } ); - - test( 'should support filter parameter', async ( { requestUtils } ) => { - const result = await listTaxonomies( requestUtils, { - active: true, - } ); - - expect( Array.isArray( result ) ).toBe( true ); - } ); - } ); - - // Get taxonomy - POST with body - - test.describe( 'scf/get-taxonomy', () => { - test.beforeEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - await createTaxonomy( requestUtils ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - } ); - - test( 'should get a taxonomy by identifier', async ( { - requestUtils, - } ) => { - const result = await getTaxonomy( requestUtils, TEST_TAXONOMY.key ); - - expect( result ).toHaveProperty( - 'taxonomy', - TEST_TAXONOMY.taxonomy - ); - expect( result ).toHaveProperty( 'title', TEST_TAXONOMY.title ); - } ); - - test( 'should return error for non-existent taxonomy', async ( { - requestUtils, - } ) => { - await expectNotFound( - getTaxonomy( requestUtils, 'nonexistent_taxonomy_abc' ) - ); - } ); - } ); - - // Export taxonomy - GET with query params (readonly) - - test.describe( 'scf/export-taxonomy', () => { - test.beforeEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - await createTaxonomy( requestUtils ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - } ); - - test( 'should export a taxonomy as JSON', async ( { - requestUtils, - } ) => { - const result = await exportTaxonomy( - requestUtils, - TEST_TAXONOMY.key - ); - - expect( result ).toHaveProperty( 'key', TEST_TAXONOMY.key ); - expect( result ).toHaveProperty( - 'taxonomy', - TEST_TAXONOMY.taxonomy - ); - expect( result ).toHaveProperty( 'title', TEST_TAXONOMY.title ); - } ); - - test( 'should return error for non-existent taxonomy', async ( { - requestUtils, - } ) => { - await expectNotFound( - exportTaxonomy( requestUtils, 'nonexistent_export' ) - ); - } ); - } ); - - // Create taxonomy - POST with body - - test.describe( 'scf/create-taxonomy', () => { - test.beforeEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - } ); - - test( 'should create a new taxonomy', async ( { requestUtils } ) => { - const result = await createTaxonomy( requestUtils ); - - expect( result ).toHaveProperty( - 'taxonomy', - TEST_TAXONOMY.taxonomy - ); - expect( result ).toHaveProperty( 'title', TEST_TAXONOMY.title ); - expect( result ).toHaveProperty( 'key', TEST_TAXONOMY.key ); - } ); - - test( 'should return error when required fields are missing', async ( { - requestUtils, - } ) => { - await expectInvalidInput( - createTaxonomy( requestUtils, { - title: 'Missing Key and Taxonomy', - } ) - ); - } ); - } ); - - // Update taxonomy - POST with body - - test.describe( 'scf/update-taxonomy', () => { - let testTaxonomyId; - - test.beforeEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - const result = await createTaxonomy( requestUtils ); - testTaxonomyId = result.ID; - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - } ); - - test( 'should update an existing taxonomy', async ( { - requestUtils, - } ) => { - const result = await updateTaxonomy( requestUtils, { - ID: testTaxonomyId, - title: 'Updated Title', - } ); - - expect( result ).toHaveProperty( 'title', 'Updated Title' ); - } ); - - test( 'should return error for non-existent taxonomy ID', async ( { - requestUtils, - } ) => { - await expectNotFound( - updateTaxonomy( requestUtils, { - ID: 999999, - title: 'Should Fail', - } ) - ); - } ); - - test( 'should return error when ID is missing', async ( { - requestUtils, - } ) => { - await expectInvalidInput( - updateTaxonomy( requestUtils, { title: 'Missing ID' } ) - ); - } ); - } ); - - // Delete taxonomy - DELETE with query params (destructive) - - test.describe( 'scf/delete-taxonomy', () => { - test.beforeEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - await createTaxonomy( requestUtils ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - } ); - - test( 'should delete an existing taxonomy', async ( { - requestUtils, - } ) => { - const result = await deleteTaxonomy( - requestUtils, - TEST_TAXONOMY.key - ); - expect( result ).toBe( true ); - - // Verify it's actually deleted - await expectNotFound( - getTaxonomy( requestUtils, TEST_TAXONOMY.key ) - ); - } ); - - test( 'should return error for non-existent taxonomy', async ( { - requestUtils, - } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - - await expectNotFound( - deleteTaxonomy( requestUtils, 'nonexistent_taxonomy_xyz' ) - ); - } ); - - test( 'should return error when identifier is missing', async ( { - requestUtils, - } ) => { - await expectInvalidInput( - requestUtils.rest( { - method: 'DELETE', - path: `${ ABILITIES_BASE }/scf/delete-taxonomy/run`, - params: { input: '' }, - } ) - ); - } ); - } ); - - // Duplicate taxonomy - POST with body - // - // Note: The duplicate receives a new unique key but retains the same - // taxonomy slug. The duplicate won't register until slug is changed. - - test.describe( 'scf/duplicate-taxonomy', () => { - let duplicatedKey; - - test.beforeEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - await createTaxonomy( requestUtils ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - if ( duplicatedKey ) { - await cleanupTaxonomy( requestUtils, duplicatedKey ); - duplicatedKey = null; - } - } ); - - test( 'should duplicate an existing taxonomy', async ( { - requestUtils, - } ) => { - const result = await duplicateTaxonomy( - requestUtils, - TEST_TAXONOMY.key - ); - duplicatedKey = result.key; - - expect( result ).toHaveProperty( 'key' ); - expect( result ).toHaveProperty( 'taxonomy' ); - expect( result.key ).not.toBe( TEST_TAXONOMY.key ); - expect( result.taxonomy ).toBe( TEST_TAXONOMY.taxonomy ); - expect( result.title ).toContain( '(copy)' ); - } ); - - test( 'should return error for non-existent taxonomy', async ( { - requestUtils, - } ) => { - await expectNotFound( - duplicateTaxonomy( - requestUtils, - 'nonexistent_duplicate_source' - ) - ); - } ); - } ); - - // Import taxonomy - POST with body - - test.describe( 'scf/import-taxonomy', () => { - test.beforeEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - } ); - - test.afterEach( async ( { requestUtils } ) => { - await cleanupTaxonomy( requestUtils, TEST_TAXONOMY.key ); - } ); - - test( 'should import a taxonomy from JSON', async ( { - requestUtils, - } ) => { - const result = await importTaxonomy( requestUtils, TEST_TAXONOMY ); - - expect( result ).toHaveProperty( - 'taxonomy', - TEST_TAXONOMY.taxonomy - ); - expect( result ).toHaveProperty( 'title', TEST_TAXONOMY.title ); - } ); - - test( 'should return error when required fields are missing', async ( { - requestUtils, - } ) => { - await expectInvalidInput( - importTaxonomy( requestUtils, { - title: 'Missing Required Fields', - } ) - ); - } ); - } ); -} ); diff --git a/tests/php/includes/abilities/test-scf-internal-post-type-abilities.php b/tests/php/includes/abilities/test-scf-internal-post-type-abilities.php new file mode 100644 index 00000000..da7d210e --- /dev/null +++ b/tests/php/includes/abilities/test-scf-internal-post-type-abilities.php @@ -0,0 +1,1009 @@ + 'taxonomy_phpunit_test', + 'title' => 'PHPUnit Test Taxonomy', + 'taxonomy' => 'phpunit_test', + ); + + /** + * Setup test fixtures + */ + public function setUp(): void { + parent::setUp(); + $this->abilities = acf_get_instance( 'SCF_Taxonomy_Abilities' ); + } + + /** + * Helper to inject a mock instance into the abilities object. + * + * @param array $method_returns Map of method names to return values. + * @return \PHPUnit\Framework\MockObject\MockObject The mock instance. + */ + private function inject_mock_instance( array $method_returns ) { + $mock_instance = $this->createMock( ACF_Taxonomy::class ); + $mock_instance->hook_name = 'acf_taxonomy'; + + foreach ( $method_returns as $method => $return_value ) { + $mock_instance->method( $method )->willReturn( $return_value ); + } + + $reflection = new ReflectionClass( SCF_Internal_Post_Type_Abilities::class ); + $property = $reflection->getProperty( 'instance' ); + $property->setAccessible( true ); + $property->setValue( $this->abilities, $mock_instance ); + + return $mock_instance; + } + + /** + * 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_instance( $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 + * + * Creates a fresh instance to ensure the add_action lines + * in the constructor are covered by code coverage tools. + */ + public function test_constructor_registers_action_hooks() { + // Create a fresh instance - this executes the constructor including add_action lines. + $fresh_instance = new SCF_Taxonomy_Abilities(); + + // Verify the hooks were registered for this specific instance. + $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(); + + // Trigger the action that constructor hooked into. + do_action( 'wp_abilities_api_categories_init' ); + + $this->assertArrayHasKey( + 'scf-taxonomies', + $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(); + + // Trigger the action that constructor hooked into. + do_action( 'wp_abilities_api_init' ); + + $this->assertArrayHasKey( + 'scf/list-taxonomies', + $mock_registered_abilities, + 'Abilities should be registered when action fires' + ); + } + + /** + * Test constructor does not register hooks when schema validation fails + */ + public function test_constructor_skips_hooks_when_schemas_invalid() { + global $acf_instances; + + // Store original validator. + $original_validator = $acf_instances['SCF_JSON_Schema_Validator'] ?? null; + + // Create mock validator that returns false. + $mock_validator = new class() { + /** + * Mock validation that always fails. + * + * @return bool Always returns false. + */ + public function validate_required_schemas() { + return false; + } + }; + $acf_instances['SCF_JSON_Schema_Validator'] = $mock_validator; + + // Create a fresh instance (bypassing acf_get_instance cache for this class). + $test_instance = new SCF_Taxonomy_Abilities(); + + // Verify NO hooks were registered for this instance. + $this->assertFalse( + has_action( 'wp_abilities_api_categories_init', array( $test_instance, 'register_categories' ) ), + 'Should NOT register hooks when schema validation fails' + ); + + // Restore original validator. + if ( $original_validator ) { + $acf_instances['SCF_JSON_Schema_Validator'] = $original_validator; + } + } + + /** + * Test constructor triggers _doing_it_wrong when internal_post_type is empty + * + * @expectedIncorrectUsage SCF_Internal_Post_Type_Abilities::__construct + */ + public function test_constructor_doing_it_wrong_when_internal_post_type_empty() { + // Create anonymous class that doesn't set internal_post_type. + $test_instance = new class() extends SCF_Internal_Post_Type_Abilities { + // Intentionally not setting $internal_post_type to trigger _doing_it_wrong. + }; + + // Verify NO hooks were registered since constructor returned early. + $this->assertFalse( + has_action( 'wp_abilities_api_categories_init', array( $test_instance, 'register_categories' ) ), + 'Should NOT register hooks when internal_post_type is empty' + ); + $this->assertFalse( + has_action( 'wp_abilities_api_init', array( $test_instance, 'register_abilities' ) ), + 'Should NOT register abilities hook when internal_post_type is empty' + ); + } + + // Registration tests. + + /** + * Test register_categories registers the scf-taxonomies category + */ + public function test_register_categories_registers_scf_taxonomies() { + global $mock_registered_ability_categories; + $mock_registered_ability_categories = array(); + + $this->abilities->register_categories(); + + $this->assertArrayHasKey( 'scf-taxonomies', $mock_registered_ability_categories ); + $this->assertArrayHasKey( 'label', $mock_registered_ability_categories['scf-taxonomies'] ); + } + + /** + * Test register_abilities registers all expected abilities + */ + public function test_register_abilities_registers_all_abilities() { + global $mock_registered_abilities; + $mock_registered_abilities = array(); + + $this->abilities->register_abilities(); + + $expected_abilities = array( + 'scf/list-taxonomies', + 'scf/get-taxonomy', + 'scf/create-taxonomy', + 'scf/update-taxonomy', + 'scf/delete-taxonomy', + 'scf/duplicate-taxonomy', + 'scf/export-taxonomy', + 'scf/import-taxonomy', + ); + + foreach ( $expected_abilities as $ability_name ) { + $this->assertArrayHasKey( $ability_name, $mock_registered_abilities, "Missing ability: $ability_name" ); + } + } + + /** + * Test registered abilities have correct category + */ + public function test_registered_abilities_have_correct_category() { + global $mock_registered_abilities; + $mock_registered_abilities = array(); + + $this->abilities->register_abilities(); + + foreach ( $mock_registered_abilities as $name => $args ) { + $this->assertEquals( + 'scf-taxonomies', + $args['category'], + "Ability $name has wrong category" + ); + } + } + + /** + * Test registered abilities have execute callbacks + */ + public function test_registered_abilities_have_execute_callbacks() { + global $mock_registered_abilities; + $mock_registered_abilities = array(); + + $this->abilities->register_abilities(); + + foreach ( $mock_registered_abilities as $name => $args ) { + $this->assertArrayHasKey( 'execute_callback', $args, "Ability $name missing execute_callback" ); + $this->assertIsCallable( $args['execute_callback'], "Ability $name execute_callback is not callable" ); + } + } + + /** + * Test registered abilities have input schemas + */ + public function test_registered_abilities_have_input_schemas() { + global $mock_registered_abilities; + $mock_registered_abilities = array(); + + $this->abilities->register_abilities(); + + foreach ( $mock_registered_abilities as $name => $args ) { + $this->assertArrayHasKey( 'input_schema', $args, "Ability $name missing input_schema" ); + } + } + + /** + * Test delete ability is marked as destructive + */ + public function test_delete_ability_is_destructive() { + global $mock_registered_abilities; + $mock_registered_abilities = array(); + + $this->abilities->register_abilities(); + + $this->assertArrayHasKey( 'scf/delete-taxonomy', $mock_registered_abilities ); + $ability = $mock_registered_abilities['scf/delete-taxonomy']; + $meta = $ability['meta'] ?? array(); + $annotations = $meta['annotations'] ?? array(); + $this->assertTrue( $annotations['destructive'] ?? false, 'Delete ability should be marked destructive' ); + } + + /** + * Test export ability is marked as readonly + */ + public function test_export_ability_is_readonly() { + global $mock_registered_abilities; + $mock_registered_abilities = array(); + + $this->abilities->register_abilities(); + + $this->assertArrayHasKey( 'scf/export-taxonomy', $mock_registered_abilities ); + $ability = $mock_registered_abilities['scf/export-taxonomy']; + $meta = $ability['meta'] ?? array(); + $annotations = $meta['annotations'] ?? array(); + $this->assertTrue( $annotations['readonly'] ?? false, 'Export ability should be marked readonly' ); + } + + /** + * Test list ability is marked as readonly + */ + public function test_list_ability_is_readonly() { + global $mock_registered_abilities; + $mock_registered_abilities = array(); + + $this->abilities->register_abilities(); + + $this->assertArrayHasKey( 'scf/list-taxonomies', $mock_registered_abilities ); + $ability = $mock_registered_abilities['scf/list-taxonomies']; + $meta = $ability['meta'] ?? array(); + $annotations = $meta['annotations'] ?? array(); + $this->assertTrue( $annotations['readonly'] ?? false, 'List ability should be marked readonly' ); + } + + /** + * Test get ability is marked as readonly + */ + public function test_get_ability_is_readonly() { + global $mock_registered_abilities; + $mock_registered_abilities = array(); + + $this->abilities->register_abilities(); + + $this->assertArrayHasKey( 'scf/get-taxonomy', $mock_registered_abilities ); + $ability = $mock_registered_abilities['scf/get-taxonomy']; + $meta = $ability['meta'] ?? array(); + $annotations = $meta['annotations'] ?? array(); + $this->assertTrue( $annotations['readonly'] ?? false, 'Get ability should be marked readonly' ); + } + + /** + * Test abilities that return data have output_schema + */ + public function test_data_returning_abilities_have_output_schema() { + global $mock_registered_abilities; + $mock_registered_abilities = array(); + + $this->abilities->register_abilities(); + + $abilities_with_output = array( + 'scf/list-taxonomies', + 'scf/get-taxonomy', + 'scf/create-taxonomy', + 'scf/update-taxonomy', + 'scf/duplicate-taxonomy', + 'scf/export-taxonomy', + 'scf/import-taxonomy', + ); + + foreach ( $abilities_with_output as $ability_name ) { + $this->assertArrayHasKey( $ability_name, $mock_registered_abilities ); + $this->assertArrayHasKey( + 'output_schema', + $mock_registered_abilities[ $ability_name ], + "Ability $ability_name should have output_schema" + ); + } + } + + /** + * Test delete ability returns boolean success schema + */ + public function test_delete_ability_has_boolean_output_schema() { + global $mock_registered_abilities; + $mock_registered_abilities = array(); + + $this->abilities->register_abilities(); + + $this->assertArrayHasKey( 'scf/delete-taxonomy', $mock_registered_abilities ); + $ability = $mock_registered_abilities['scf/delete-taxonomy']; + $this->assertArrayHasKey( 'output_schema', $ability ); + // Delete returns boolean true on success. + $this->assertEquals( 'boolean', $ability['output_schema']['type'] ); + } + + // Callback tests. + + /** + * Test list_callback returns array + * + * Note: Full CRUD flow tests require WordPress post persistence + * which WorDBless doesn't fully support. These are tested in E2E. + */ + public function test_list_taxonomies_callback_returns_array() { + $result = $this->abilities->list_callback( array() ); + + $this->assertIsArray( $result ); + } + + /** + * Test list_callback with filter parameter + */ + public function test_list_taxonomies_callback_with_filter() { + $result = $this->abilities->list_callback( + array( 'filter' => array( 'active' => true ) ) + ); + + $this->assertIsArray( $result ); + } + + /** + * Test get_callback returns WP_Error with not_found code and 404 status for non-existent entity + */ + public function test_get_taxonomy_callback_not_found() { + $result = $this->abilities->get_callback( + array( 'identifier' => 'nonexistent_taxonomy' ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'not_found', $result->get_error_code() ); + $this->assertEquals( 404, $result->get_error_data()['status'] ); + } + + /** + * Test create_callback returns array with expected fields + */ + public function test_create_taxonomy_callback_returns_expected_fields() { + $result = $this->abilities->create_callback( $this->test_taxonomy ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'key', $result ); + $this->assertEquals( $this->test_taxonomy['key'], $result['key'] ); + $this->assertArrayHasKey( 'title', $result ); + $this->assertEquals( $this->test_taxonomy['title'], $result['title'] ); + } + + /** + * Test update_callback returns WP_Error for non-existent ID + */ + public function test_update_taxonomy_callback_not_found_returns_error() { + $result = $this->abilities->update_callback( + array( + 'ID' => 999999, + 'title' => 'Should Fail', + ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'not_found', $result->get_error_code() ); + } + + /** + * Test delete_callback returns WP_Error with not_found code and 404 status for non-existent entity + */ + public function test_delete_taxonomy_callback_not_found() { + $result = $this->abilities->delete_callback( + array( 'identifier' => 'nonexistent_delete_target' ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'not_found', $result->get_error_code() ); + $this->assertEquals( 404, $result->get_error_data()['status'] ); + } + + /** + * Test duplicate_callback returns WP_Error for non-existent ID + */ + public function test_duplicate_taxonomy_callback_not_found_returns_error() { + $result = $this->abilities->duplicate_callback( + array( 'identifier' => 999999 ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'not_found', $result->get_error_code() ); + } + + /** + * Test export_callback returns WP_Error for non-existent ID + */ + public function test_export_taxonomy_callback_not_found_returns_error() { + $result = $this->abilities->export_callback( + array( 'identifier' => 999999 ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'not_found', $result->get_error_code() ); + } + + /** + * Test import_callback returns array with expected fields + */ + public function test_import_taxonomy_callback_returns_expected_fields() { + $result = $this->abilities->import_callback( $this->test_taxonomy ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->test_taxonomy['taxonomy'], $result['taxonomy'] ); + $this->assertEquals( $this->test_taxonomy['title'], $result['title'] ); + } + + // Schema method tests. + + /** + * Test get_entity_schema returns valid schema with required fields + */ + public function test_get_entity_schema() { + $reflection = new ReflectionClass( $this->abilities ); + $method = $reflection->getMethod( 'get_entity_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->abilities ); + + $this->assertIsArray( $schema ); + $this->assertArrayHasKey( 'type', $schema ); + $this->assertEquals( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'required', $schema ); + $this->assertContains( 'key', $schema['required'] ); + $this->assertContains( 'title', $schema['required'] ); + $this->assertContains( 'taxonomy', $schema['required'] ); + } + + /** + * Test get_scf_identifier_schema returns valid schema + */ + public function test_get_scf_identifier_schema_returns_array() { + $reflection = new ReflectionClass( $this->abilities ); + $method = $reflection->getMethod( 'get_scf_identifier_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->abilities ); + + $this->assertIsArray( $schema ); + // Should have description and allow integer or string. + $this->assertArrayHasKey( 'description', $schema ); + } + + /** + * Test get_internal_fields_schema returns valid schema with ID property + */ + public function test_get_internal_fields_schema() { + $reflection = new ReflectionClass( $this->abilities ); + $method = $reflection->getMethod( 'get_internal_fields_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->abilities ); + + $this->assertIsArray( $schema ); + $this->assertArrayHasKey( 'properties', $schema ); + $this->assertArrayHasKey( 'ID', $schema['properties'] ); + } + + /** + * Test get_entity_with_internal_fields_schema returns merged schema with both entity and internal fields + */ + public function test_get_entity_with_internal_fields_schema() { + $reflection = new ReflectionClass( $this->abilities ); + $method = $reflection->getMethod( 'get_entity_with_internal_fields_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->abilities ); + + $this->assertIsArray( $schema ); + $this->assertArrayHasKey( 'properties', $schema ); + // Should have entity-specific field (taxonomy has 'taxonomy' field). + $this->assertArrayHasKey( 'taxonomy', $schema['properties'], 'Should have entity-specific taxonomy field' ); + // Should have internal field (ID). + $this->assertArrayHasKey( 'ID', $schema['properties'], 'Should have internal ID field' ); + } + + // Private method tests. + + /** + * Test not_found_error returns WP_Error with correct code and 404 status + */ + public function test_not_found_error() { + $reflection = new ReflectionClass( $this->abilities ); + $method = $reflection->getMethod( 'not_found_error' ); + $method->setAccessible( true ); + + $error = $method->invoke( $this->abilities ); + + $this->assertInstanceOf( WP_Error::class, $error ); + $this->assertEquals( 'not_found', $error->get_error_code() ); + $this->assertEquals( 404, $error->get_error_data()['status'] ); + } + + /** + * Test entity_name returns correct value for taxonomy + */ + public function test_entity_name_returns_taxonomy() { + $reflection = new ReflectionClass( $this->abilities ); + $method = $reflection->getMethod( 'entity_name' ); + $method->setAccessible( true ); + + $this->assertEquals( 'taxonomy', $method->invoke( $this->abilities ) ); + } + + /** + * Test entity_name_plural returns correct value for taxonomy + */ + public function test_entity_name_plural_returns_taxonomies() { + $reflection = new ReflectionClass( $this->abilities ); + $method = $reflection->getMethod( 'entity_name_plural' ); + $method->setAccessible( true ); + + $this->assertEquals( 'taxonomies', $method->invoke( $this->abilities ) ); + } + + /** + * Test schema_name returns correct schema file name + */ + public function test_schema_name_returns_taxonomy() { + $reflection = new ReflectionClass( $this->abilities ); + $method = $reflection->getMethod( 'schema_name' ); + $method->setAccessible( true ); + + $this->assertEquals( 'taxonomy', $method->invoke( $this->abilities ) ); + } + + /** + * Test ability_category returns correct category + */ + public function test_ability_category_returns_scf_taxonomies() { + $reflection = new ReflectionClass( $this->abilities ); + $method = $reflection->getMethod( 'ability_category' ); + $method->setAccessible( true ); + + $this->assertEquals( 'scf-taxonomies', $method->invoke( $this->abilities ) ); + } + + /** + * Test ability_name uses plural for list action + */ + public function test_ability_name_uses_plural_for_list() { + $reflection = new ReflectionClass( $this->abilities ); + $method = $reflection->getMethod( 'ability_name' ); + $method->setAccessible( true ); + + $this->assertEquals( 'scf/list-taxonomies', $method->invoke( $this->abilities, 'list' ) ); + } + + /** + * Test ability_name uses singular for non-list actions + */ + public function test_ability_name_uses_singular_for_get() { + $reflection = new ReflectionClass( $this->abilities ); + $method = $reflection->getMethod( 'ability_name' ); + $method->setAccessible( true ); + + $this->assertEquals( 'scf/get-taxonomy', $method->invoke( $this->abilities, 'get' ) ); + } + + /** + * Test instance() method caches result + */ + public function test_instance_caches_result() { + $reflection = new ReflectionClass( SCF_Internal_Post_Type_Abilities::class ); + + // Access the private $instance property from base class. + $property = $reflection->getProperty( 'instance' ); + $property->setAccessible( true ); + + // Reset instance to null for this test. + $property->setValue( $this->abilities, null ); + + // Initially should be null after reset. + $this->assertNull( $property->getValue( $this->abilities ), 'Instance should be null after reset' ); + + // Call instance() method. + $method = $reflection->getMethod( 'instance' ); + $method->setAccessible( true ); + $first_call = $method->invoke( $this->abilities ); + + // Should now be cached. + $cached = $property->getValue( $this->abilities ); + $this->assertNotNull( $cached, 'Instance should be cached after first call' ); + $this->assertSame( $first_call, $cached, 'Cached value should match first call result' ); + + // Second call should return same cached instance. + $second_call = $method->invoke( $this->abilities ); + $this->assertSame( $first_call, $second_call, 'Second call should return cached instance' ); + } + + // Mocked callback tests. + + /** + * Test create_callback returns error for duplicate key + */ + public function test_create_callback_returns_error_for_duplicate_key() { + $this->assert_callback_error( + array( + 'get_post' => array( + 'ID' => 123, + 'key' => 'existing_key', + ), + ), + array( $this->abilities, 'create_callback' ), + array( 'key' => 'existing_key' ), + 'already_exists' + ); + } + + /** + * Test create_callback succeeds when no duplicate + */ + public function test_create_callback_success() { + $created_entity = array( + 'ID' => 456, + 'key' => 'new_key', + 'title' => 'New Taxonomy', + ); + + $this->inject_mock_instance( + array( + 'get_post' => null, + 'update_post' => $created_entity, + ) + ); + + $result = $this->abilities->create_callback( + array( + 'key' => 'new_key', + 'title' => 'New Taxonomy', + ) + ); + + $this->assertIsArray( $result ); + $this->assertEquals( 456, $result['ID'] ); + $this->assertEquals( 'new_key', $result['key'] ); + } + + /** + * Test update_callback succeeds with valid entity + */ + public function test_update_callback_success() { + $existing_entity = array( + 'ID' => 123, + 'key' => 'test_key', + 'title' => 'Old Title', + ); + $updated_entity = array( + 'ID' => 123, + 'key' => 'test_key', + 'title' => 'New Title', + ); + + $this->inject_mock_instance( + array( + 'get_post' => $existing_entity, + 'update_post' => $updated_entity, + ) + ); + + $result = $this->abilities->update_callback( + array( + 'ID' => 123, + 'title' => 'New Title', + ) + ); + + $this->assertIsArray( $result ); + $this->assertEquals( 123, $result['ID'] ); + $this->assertEquals( 'New Title', $result['title'] ); + } + + /** + * Test delete_callback succeeds when entity exists + */ + public function test_delete_callback_success() { + $this->inject_mock_instance( + array( + 'get_post' => array( + 'ID' => 123, + 'key' => 'test_key', + ), + 'delete_post' => true, + ) + ); + + $result = $this->abilities->delete_callback( array( 'identifier' => 123 ) ); + + $this->assertTrue( $result ); + } + + /** + * Test duplicate_callback succeeds when entity exists + */ + public function test_duplicate_callback_success() { + $existing_entity = array( + 'ID' => 123, + 'key' => 'test_key', + ); + $duplicated_entity = array( + 'ID' => 456, + 'key' => 'test_key_copy', + ); + + $this->inject_mock_instance( + array( + 'get_post' => $existing_entity, + 'duplicate_post' => $duplicated_entity, + ) + ); + + $result = $this->abilities->duplicate_callback( array( 'identifier' => 123 ) ); + + $this->assertIsArray( $result ); + $this->assertEquals( 456, $result['ID'] ); + } + + /** + * Test duplicate_callback returns error when duplication fails + */ + public function test_duplicate_callback_returns_error_on_failure() { + $this->assert_callback_error( + array( + 'get_post' => array( + 'ID' => 123, + 'key' => 'test_key', + ), + 'duplicate_post' => false, + ), + array( $this->abilities, 'duplicate_callback' ), + array( 'identifier' => 123 ), + 'duplicate_failed' + ); + } + + /** + * Test export_callback succeeds when entity exists + */ + public function test_export_callback_success() { + $existing_entity = array( + 'ID' => 123, + 'key' => 'test_key', + 'title' => 'Test Taxonomy', + ); + $exported_data = array( + 'key' => 'test_key', + 'title' => 'Test Taxonomy', + ); + + $this->inject_mock_instance( + array( + 'get_post' => $existing_entity, + 'prepare_post_for_export' => $exported_data, + ) + ); + + $result = $this->abilities->export_callback( array( 'identifier' => 123 ) ); + + $this->assertIsArray( $result ); + $this->assertEquals( 'test_key', $result['key'] ); + } + + /** + * Test export_callback returns error when export fails + */ + public function test_export_callback_returns_error_on_failure() { + $this->assert_callback_error( + array( + 'get_post' => array( + 'ID' => 123, + 'key' => 'test_key', + ), + 'prepare_post_for_export' => false, + ), + array( $this->abilities, 'export_callback' ), + array( 'identifier' => 123 ), + 'export_failed' + ); + } + + /** + * Test create_callback returns error when creation fails + */ + public function test_create_callback_returns_error_on_failure() { + $this->assert_callback_error( + array( + 'get_post' => null, + 'update_post' => false, + ), + array( $this->abilities, 'create_callback' ), + array( 'key' => 'new_key' ), + 'create_failed' + ); + } + + /** + * Test update_callback returns error when update fails + */ + public function test_update_callback_returns_error_on_failure() { + $this->assert_callback_error( + array( + 'get_post' => array( + 'ID' => 123, + 'key' => 'test_key', + ), + 'update_post' => false, + ), + array( $this->abilities, 'update_callback' ), + array( + 'ID' => 123, + 'title' => 'New Title', + ), + 'update_failed' + ); + } + + /** + * Test delete_callback returns error when deletion fails + */ + public function test_delete_callback_returns_error_on_failure() { + $this->assert_callback_error( + array( + 'get_post' => array( + 'ID' => 123, + 'key' => 'test_key', + ), + 'delete_post' => false, + ), + array( $this->abilities, 'delete_callback' ), + array( 'identifier' => 123 ), + 'delete_failed' + ); + } + + /** + * Test import_callback returns error when import fails + */ + public function test_import_callback_returns_error_on_failure() { + $this->assert_callback_error( + array( 'import_post' => false ), + array( $this->abilities, 'import_callback' ), + array( 'key' => 'test_key' ), + 'import_failed' + ); + } + + /** + * Test get_callback returns entity when found + */ + public function test_get_callback_success() { + $entity = array( + 'ID' => 123, + 'key' => 'test_key', + 'title' => 'Test Taxonomy', + ); + + $this->inject_mock_instance( + array( + 'get_post' => $entity, + ) + ); + + $result = $this->abilities->get_callback( array( 'identifier' => 123 ) ); + + $this->assertIsArray( $result ); + $this->assertEquals( 123, $result['ID'] ); + $this->assertEquals( 'test_key', $result['key'] ); + } + + /** + * Test list_callback returns filtered entities + */ + public function test_list_callback_success() { + $entities = array( + array( + 'ID' => 1, + 'key' => 'tax_1', + ), + array( + 'ID' => 2, + 'key' => 'tax_2', + ), + ); + + $this->inject_mock_instance( + array( + 'get_posts' => $entities, + 'filter_posts' => $entities, + ) + ); + + $result = $this->abilities->list_callback( array() ); + + $this->assertIsArray( $result ); + $this->assertCount( 2, $result ); + } +} diff --git a/tests/php/includes/abilities/test-scf-post-type-abilities.php b/tests/php/includes/abilities/test-scf-post-type-abilities.php deleted file mode 100644 index db22ce79..00000000 --- a/tests/php/includes/abilities/test-scf-post-type-abilities.php +++ /dev/null @@ -1,207 +0,0 @@ - 'post_type_phpunit_test', - 'title' => 'PHPUnit Test Type', - 'post_type' => 'phpunit_test', - ); - - /** - * Setup test fixtures - */ - public function setUp(): void { - parent::setUp(); - $this->abilities = acf_get_instance( 'SCF_Post_Type_Abilities' ); - } - - /** - * Test list_post_types_callback returns array - */ - public function test_list_post_types_returns_array() { - $result = $this->abilities->list_post_types_callback( array() ); - - $this->assertIsArray( $result ); - } - - /** - * Test list_post_types_callback with filter parameter - */ - public function test_list_post_types_with_filter() { - $result = $this->abilities->list_post_types_callback( - array( 'filter' => array( 'active' => true ) ) - ); - - $this->assertIsArray( $result ); - } - - /** - * Test create_post_type_callback returns array with key - */ - public function test_create_post_type_returns_array_with_key() { - $result = $this->abilities->create_post_type_callback( $this->test_post_type ); - - $this->assertIsArray( $result ); - $this->assertArrayHasKey( 'key', $result ); - $this->assertEquals( $this->test_post_type['key'], $result['key'] ); - } - - /** - * Test create_post_type_callback returns array with title - */ - public function test_create_post_type_returns_array_with_title() { - $result = $this->abilities->create_post_type_callback( $this->test_post_type ); - - $this->assertIsArray( $result ); - $this->assertArrayHasKey( 'title', $result ); - $this->assertEquals( $this->test_post_type['title'], $result['title'] ); - } - - /** - * Test get_post_type_callback returns WP_Error for non-existent ID - */ - public function test_get_post_type_not_found_returns_error() { - $result = $this->abilities->get_post_type_callback( - array( 'identifier' => 999999 ) - ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertEquals( 'post_type_not_found', $result->get_error_code() ); - } - - /** - * Test get_post_type_callback returns 404 status for non-existent key - */ - public function test_get_post_type_not_found_returns_404_status() { - $result = $this->abilities->get_post_type_callback( - array( 'identifier' => 'nonexistent_post_type' ) - ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $error_data = $result->get_error_data(); - $this->assertEquals( 404, $error_data['status'] ); - } - - /** - * Test update_post_type_callback returns WP_Error for non-existent ID - */ - public function test_update_post_type_not_found_returns_error() { - $result = $this->abilities->update_post_type_callback( - array( - 'ID' => 999999, - 'title' => 'Should Fail', - ) - ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertEquals( 'post_type_not_found', $result->get_error_code() ); - } - - /** - * Test delete_post_type_callback returns WP_Error for non-existent ID - */ - public function test_delete_post_type_not_found_returns_error() { - $result = $this->abilities->delete_post_type_callback( - array( 'identifier' => 999999 ) - ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertEquals( 'post_type_not_found', $result->get_error_code() ); - } - - /** - * Test delete_post_type_callback returns 404 status for non-existent key - */ - public function test_delete_post_type_not_found_returns_404_status() { - $result = $this->abilities->delete_post_type_callback( - array( 'identifier' => 'nonexistent_delete_target' ) - ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $error_data = $result->get_error_data(); - $this->assertEquals( 404, $error_data['status'] ); - } - - /** - * Test duplicate_post_type_callback returns WP_Error for non-existent ID - */ - public function test_duplicate_post_type_not_found_returns_error() { - $result = $this->abilities->duplicate_post_type_callback( - array( 'identifier' => 999999 ) - ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertEquals( 'post_type_not_found', $result->get_error_code() ); - } - - /** - * Test export_post_type_callback returns WP_Error for non-existent ID - */ - public function test_export_post_type_not_found_returns_error() { - $result = $this->abilities->export_post_type_callback( - array( 'identifier' => 999999 ) - ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertEquals( 'post_type_not_found', $result->get_error_code() ); - } - - /** - * Test import_post_type_callback returns array - */ - public function test_import_post_type_returns_array() { - $result = $this->abilities->import_post_type_callback( $this->test_post_type ); - - $this->assertIsArray( $result ); - } - - /** - * Test import_post_type_callback returns correct post_type - */ - public function test_import_post_type_returns_correct_post_type() { - $result = $this->abilities->import_post_type_callback( $this->test_post_type ); - - $this->assertIsArray( $result ); - $this->assertEquals( $this->test_post_type['post_type'], $result['post_type'] ); - } - - /** - * Test import_post_type_callback returns correct title - */ - public function test_import_post_type_returns_correct_title() { - $result = $this->abilities->import_post_type_callback( $this->test_post_type ); - - $this->assertIsArray( $result ); - $this->assertEquals( $this->test_post_type['title'], $result['title'] ); - } -} diff --git a/tests/php/includes/abilities/test-scf-taxonomy-abilities.php b/tests/php/includes/abilities/test-scf-taxonomy-abilities.php deleted file mode 100644 index efefc293..00000000 --- a/tests/php/includes/abilities/test-scf-taxonomy-abilities.php +++ /dev/null @@ -1,466 +0,0 @@ - 'taxonomy_phpunit_test', - 'title' => 'PHPUnit Test Taxonomy', - 'taxonomy' => 'phpunit_test', - ); - - /** - * Setup test fixtures - */ - public function setUp(): void { - parent::setUp(); - $this->abilities = acf_get_instance( 'SCF_Taxonomy_Abilities' ); - } - - // Constructor tests. - - /** - * Test constructor registers WordPress action hooks - */ - public function test_constructor_registers_action_hooks() { - // The constructor should have registered these action hooks. - $this->assertNotFalse( - has_action( 'wp_abilities_api_categories_init', array( $this->abilities, 'register_categories' ) ), - 'Should register wp_abilities_api_categories_init action' - ); - $this->assertNotFalse( - has_action( 'wp_abilities_api_init', array( $this->abilities, 'register_abilities' ) ), - 'Should register wp_abilities_api_init action' - ); - } - - /** - * Test constructor does not register hooks when schema validation fails - */ - public function test_constructor_skips_hooks_when_schemas_invalid() { - global $acf_instances; - - // Store original validator. - $original_validator = $acf_instances['SCF_JSON_Schema_Validator'] ?? null; - - // Create mock validator that returns false. - $mock_validator = new class() { - /** - * Mock validation that always fails. - * - * @return bool Always returns false. - */ - public function validate_required_schemas() { - return false; - } - }; - $acf_instances['SCF_JSON_Schema_Validator'] = $mock_validator; - - // Create a fresh instance (bypassing acf_get_instance cache for this class). - $test_instance = new SCF_Taxonomy_Abilities(); - - // Verify NO hooks were registered for this instance. - $this->assertFalse( - has_action( 'wp_abilities_api_categories_init', array( $test_instance, 'register_categories' ) ), - 'Should NOT register hooks when schema validation fails' - ); - - // Restore original validator. - if ( $original_validator ) { - $acf_instances['SCF_JSON_Schema_Validator'] = $original_validator; - } - } - - // Registration tests. - - /** - * Test register_categories registers the scf-taxonomies category - */ - public function test_register_categories_registers_scf_taxonomies() { - global $mock_registered_ability_categories; - $mock_registered_ability_categories = array(); - - $this->abilities->register_categories(); - - $this->assertArrayHasKey( 'scf-taxonomies', $mock_registered_ability_categories ); - $this->assertArrayHasKey( 'label', $mock_registered_ability_categories['scf-taxonomies'] ); - } - - /** - * Test register_abilities registers all expected abilities - */ - public function test_register_abilities_registers_all_abilities() { - global $mock_registered_abilities; - $mock_registered_abilities = array(); - - $this->abilities->register_abilities(); - - $expected_abilities = array( - 'scf/list-taxonomies', - 'scf/get-taxonomy', - 'scf/create-taxonomy', - 'scf/update-taxonomy', - 'scf/delete-taxonomy', - 'scf/duplicate-taxonomy', - 'scf/export-taxonomy', - 'scf/import-taxonomy', - ); - - foreach ( $expected_abilities as $ability_name ) { - $this->assertArrayHasKey( $ability_name, $mock_registered_abilities, "Missing ability: $ability_name" ); - } - } - - /** - * Test registered abilities have correct category - */ - public function test_registered_abilities_have_correct_category() { - global $mock_registered_abilities; - $mock_registered_abilities = array(); - - $this->abilities->register_abilities(); - - foreach ( $mock_registered_abilities as $name => $args ) { - $this->assertEquals( - 'scf-taxonomies', - $args['category'], - "Ability $name has wrong category" - ); - } - } - - /** - * Test registered abilities have execute callbacks - */ - public function test_registered_abilities_have_execute_callbacks() { - global $mock_registered_abilities; - $mock_registered_abilities = array(); - - $this->abilities->register_abilities(); - - foreach ( $mock_registered_abilities as $name => $args ) { - $this->assertArrayHasKey( 'execute_callback', $args, "Ability $name missing execute_callback" ); - $this->assertIsCallable( $args['execute_callback'], "Ability $name execute_callback is not callable" ); - } - } - - /** - * Test registered abilities have input schemas - */ - public function test_registered_abilities_have_input_schemas() { - global $mock_registered_abilities; - $mock_registered_abilities = array(); - - $this->abilities->register_abilities(); - - foreach ( $mock_registered_abilities as $name => $args ) { - $this->assertArrayHasKey( 'input_schema', $args, "Ability $name missing input_schema" ); - } - } - - /** - * Test delete ability is marked as destructive - */ - public function test_delete_ability_is_destructive() { - global $mock_registered_abilities; - $mock_registered_abilities = array(); - - $this->abilities->register_abilities(); - - $this->assertArrayHasKey( 'scf/delete-taxonomy', $mock_registered_abilities ); - $ability = $mock_registered_abilities['scf/delete-taxonomy']; - $meta = $ability['meta'] ?? array(); - $annotations = $meta['annotations'] ?? array(); - $this->assertTrue( $annotations['destructive'] ?? false, 'Delete ability should be marked destructive' ); - } - - /** - * Test export ability is marked as readonly - */ - public function test_export_ability_is_readonly() { - global $mock_registered_abilities; - $mock_registered_abilities = array(); - - $this->abilities->register_abilities(); - - $this->assertArrayHasKey( 'scf/export-taxonomy', $mock_registered_abilities ); - $ability = $mock_registered_abilities['scf/export-taxonomy']; - $meta = $ability['meta'] ?? array(); - $annotations = $meta['annotations'] ?? array(); - $this->assertTrue( $annotations['readonly'] ?? false, 'Export ability should be marked readonly' ); - } - - // Callback tests. - - /** - * Test list_taxonomies_callback returns array - * - * Note: Full CRUD flow tests require WordPress post persistence - * which WorDBless doesn't fully support. These are tested in E2E. - */ - public function test_list_taxonomies_callback_returns_array() { - $result = $this->abilities->list_taxonomies_callback( array() ); - - $this->assertIsArray( $result ); - } - - /** - * Test list_taxonomies_callback with filter parameter - */ - public function test_list_taxonomies_callback_with_filter() { - $result = $this->abilities->list_taxonomies_callback( - array( 'filter' => array( 'active' => true ) ) - ); - - $this->assertIsArray( $result ); - } - - /** - * Test get_taxonomy_callback returns WP_Error for non-existent ID - */ - public function test_get_taxonomy_callback_not_found_returns_error() { - $result = $this->abilities->get_taxonomy_callback( - array( 'identifier' => 999999 ) - ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertEquals( 'taxonomy_not_found', $result->get_error_code() ); - } - - /** - * Test get_taxonomy_callback returns 404 status for non-existent key - */ - public function test_get_taxonomy_callback_not_found_returns_404_status() { - $result = $this->abilities->get_taxonomy_callback( - array( 'identifier' => 'nonexistent_taxonomy' ) - ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $error_data = $result->get_error_data(); - $this->assertEquals( 404, $error_data['status'] ); - } - - /** - * Test create_taxonomy_callback returns array with key - */ - public function test_create_taxonomy_callback_returns_array_with_key() { - $result = $this->abilities->create_taxonomy_callback( $this->test_taxonomy ); - - $this->assertIsArray( $result ); - $this->assertArrayHasKey( 'key', $result ); - $this->assertEquals( $this->test_taxonomy['key'], $result['key'] ); - } - - /** - * Test create_taxonomy_callback returns array with title - */ - public function test_create_taxonomy_callback_returns_array_with_title() { - $result = $this->abilities->create_taxonomy_callback( $this->test_taxonomy ); - - $this->assertIsArray( $result ); - $this->assertArrayHasKey( 'title', $result ); - $this->assertEquals( $this->test_taxonomy['title'], $result['title'] ); - } - - /** - * Test update_taxonomy_callback returns WP_Error for non-existent ID - */ - public function test_update_taxonomy_callback_not_found_returns_error() { - $result = $this->abilities->update_taxonomy_callback( - array( - 'ID' => 999999, - 'title' => 'Should Fail', - ) - ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertEquals( 'taxonomy_not_found', $result->get_error_code() ); - } - - /** - * Test delete_taxonomy_callback returns WP_Error for non-existent ID - */ - public function test_delete_taxonomy_callback_not_found_returns_error() { - $result = $this->abilities->delete_taxonomy_callback( - array( 'identifier' => 999999 ) - ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertEquals( 'taxonomy_not_found', $result->get_error_code() ); - } - - /** - * Test delete_taxonomy_callback returns 404 status for non-existent key - */ - public function test_delete_taxonomy_callback_not_found_returns_404_status() { - $result = $this->abilities->delete_taxonomy_callback( - array( 'identifier' => 'nonexistent_delete_target' ) - ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $error_data = $result->get_error_data(); - $this->assertEquals( 404, $error_data['status'] ); - } - - /** - * Test duplicate_taxonomy_callback returns WP_Error for non-existent ID - */ - public function test_duplicate_taxonomy_callback_not_found_returns_error() { - $result = $this->abilities->duplicate_taxonomy_callback( - array( 'identifier' => 999999 ) - ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertEquals( 'taxonomy_not_found', $result->get_error_code() ); - } - - /** - * Test export_taxonomy_callback returns WP_Error for non-existent ID - */ - public function test_export_taxonomy_callback_not_found_returns_error() { - $result = $this->abilities->export_taxonomy_callback( - array( 'identifier' => 999999 ) - ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertEquals( 'taxonomy_not_found', $result->get_error_code() ); - } - - /** - * Test import_taxonomy_callback returns array - */ - public function test_import_taxonomy_callback_returns_array() { - $result = $this->abilities->import_taxonomy_callback( $this->test_taxonomy ); - - $this->assertIsArray( $result ); - } - - /** - * Test import_taxonomy_callback returns correct taxonomy - */ - public function test_import_taxonomy_callback_returns_correct_taxonomy() { - $result = $this->abilities->import_taxonomy_callback( $this->test_taxonomy ); - - $this->assertIsArray( $result ); - $this->assertEquals( $this->test_taxonomy['taxonomy'], $result['taxonomy'] ); - } - - /** - * Test import_taxonomy_callback returns correct title - */ - public function test_import_taxonomy_callback_returns_correct_title() { - $result = $this->abilities->import_taxonomy_callback( $this->test_taxonomy ); - - $this->assertIsArray( $result ); - $this->assertEquals( $this->test_taxonomy['title'], $result['title'] ); - } - - // Schema tests. - - /** - * Test get_taxonomy_schema returns valid schema - */ - public function test_get_taxonomy_schema_returns_array() { - $reflection = new ReflectionClass( $this->abilities ); - $method = $reflection->getMethod( 'get_taxonomy_schema' ); - $method->setAccessible( true ); - - $schema = $method->invoke( $this->abilities ); - - $this->assertIsArray( $schema ); - $this->assertArrayHasKey( 'type', $schema ); - $this->assertEquals( 'object', $schema['type'] ); - } - - /** - * Test get_taxonomy_schema has required fields - */ - public function test_get_taxonomy_schema_has_required_fields() { - $reflection = new ReflectionClass( $this->abilities ); - $method = $reflection->getMethod( 'get_taxonomy_schema' ); - $method->setAccessible( true ); - - $schema = $method->invoke( $this->abilities ); - - $this->assertArrayHasKey( 'required', $schema ); - $this->assertContains( 'key', $schema['required'] ); - $this->assertContains( 'title', $schema['required'] ); - $this->assertContains( 'taxonomy', $schema['required'] ); - } - - /** - * Test get_scf_identifier_schema returns valid schema - */ - public function test_get_scf_identifier_schema_returns_array() { - $reflection = new ReflectionClass( $this->abilities ); - $method = $reflection->getMethod( 'get_scf_identifier_schema' ); - $method->setAccessible( true ); - - $schema = $method->invoke( $this->abilities ); - - $this->assertIsArray( $schema ); - // Should have description and allow integer or string. - $this->assertArrayHasKey( 'description', $schema ); - } - - // Helper method tests. - - /** - * Test taxonomy_not_found_error returns correct error code - */ - public function test_taxonomy_not_found_error_returns_correct_code() { - $reflection = new ReflectionClass( $this->abilities ); - $method = $reflection->getMethod( 'taxonomy_not_found_error' ); - $method->setAccessible( true ); - - $error = $method->invoke( $this->abilities ); - - $this->assertInstanceOf( WP_Error::class, $error ); - $this->assertEquals( 'taxonomy_not_found', $error->get_error_code() ); - } - - /** - * Test taxonomy_not_found_error returns 404 status - */ - public function test_taxonomy_not_found_error_returns_404() { - $reflection = new ReflectionClass( $this->abilities ); - $method = $reflection->getMethod( 'taxonomy_not_found_error' ); - $method->setAccessible( true ); - - $error = $method->invoke( $this->abilities ); - $error_data = $error->get_error_data(); - - $this->assertEquals( 404, $error_data['status'] ); - } -}