diff --git a/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings-get.php b/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings-get.php new file mode 100644 index 000000000..5fc4791bb --- /dev/null +++ b/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings-get.php @@ -0,0 +1,135 @@ + 'object', + 'required' => array( 'term_id' ), + 'properties' => array( + 'term_id' => array( + 'type' => 'integer', + 'description' => __( 'The Category (term) ID to read Kit settings for.', 'convertkit' ), + 'minimum' => 1, + ), + ), + ); + + } + + /** + * Executes the ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + $term_id = isset( $input['term_id'] ) ? absint( $input['term_id'] ) : 0; + + // Bail if the term does not exist or is not a Category. + $valid = $this->validate_term( $term_id ); + if ( is_wp_error( $valid ) ) { + return $valid; + } + + // Load the Category's settings. + $term_settings = new ConvertKit_Term( $term_id ); + $settings = $term_settings->get(); + + // Cast `form` to int and `form_position` to string so the output + // exactly matches the declared schema, regardless of how the value + // was stored (defaults may be '' for form, but the schema wants int). + $form = isset( $settings['form'] ) && $settings['form'] !== '' ? (int) $settings['form'] : 0; + $form_position = isset( $settings['form_position'] ) ? (string) $settings['form_position'] : ''; + + return array( + 'term_id' => $term_id, + 'form' => $form, + 'form_position' => $form_position, + ); + + } + +} diff --git a/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings-update.php b/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings-update.php new file mode 100644 index 000000000..71341d60e --- /dev/null +++ b/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings-update.php @@ -0,0 +1,163 @@ + 'object', + 'required' => array( 'term_id' ), + 'properties' => array_merge( + array( + 'term_id' => array( + 'type' => 'integer', + 'description' => __( 'The Category (term) ID to update Kit settings for.', 'convertkit' ), + 'minimum' => 1, + ), + ), + $this->get_settings_schema_properties() + ), + ); + + } + + /** + * Executes the ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + $term_id = isset( $input['term_id'] ) ? absint( $input['term_id'] ) : 0; + + // Bail if the term does not exist or is not a Category. + $valid = $this->validate_term( $term_id ); + if ( is_wp_error( $valid ) ) { + return $valid; + } + + // Reject unknown keys. + $properties = $this->get_settings_schema_properties(); + $allowed_keys = array_merge( array( 'term_id' ), array_keys( $properties ) ); + $unknown_keys = array_diff( array_keys( $input ), $allowed_keys ); + if ( ! empty( $unknown_keys ) ) { + return new WP_Error( + 'convertkit_mcp_category_settings_unknown_keys', + sprintf( + /* translators: %s: Comma-separated list of unknown keys. */ + __( 'The following settings keys are not recognised: %s.', 'convertkit' ), + implode( ', ', $unknown_keys ) + ) + ); + } + + // Validate each provided setting against its declared schema. + $validated = array(); + foreach ( $properties as $key => $property_schema ) { + if ( ! array_key_exists( $key, $input ) ) { + continue; + } + + $valid = rest_validate_value_from_schema( $input[ $key ], $property_schema, $key ); + + // Bail if the value is invalid. + if ( is_wp_error( $valid ) ) { + return $valid; + } + + $validated[ $key ] = rest_sanitize_value_from_schema( $input[ $key ], $property_schema, $key ); + } + + // Bail if no settings were provided. + if ( empty( $validated ) ) { + return new WP_Error( + 'convertkit_mcp_category_settings_no_input', + __( 'At least one setting (form or form_position) must be provided.', 'convertkit' ) + ); + } + + // Save. ConvertKit_Term::save() merges the provided values into the + // term's existing settings internally, so this is a partial update. + $term_settings = new ConvertKit_Term( $term_id ); + $term_settings->save( $validated ); + + // Return the post-save state, using the get ability so the shape + // exactly matches kit/category-settings-get. + $get_ability = new ConvertKit_MCP_Ability_Category_Settings_Get(); + return $get_ability->execute_callback( array( 'term_id' => $term_id ) ); + + } + +} diff --git a/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings.php b/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings.php new file mode 100644 index 000000000..aadb33618 --- /dev/null +++ b/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings.php @@ -0,0 +1,187 @@ +`. + * + * Scope is limited to the `category` taxonomy, matching the admin UI + * (ConvertKit_Admin_Category) and the frontend read path + * (ConvertKit_Output::get_term_form_position()). + * + * @package ConvertKit + * @author ConvertKit + */ +abstract class ConvertKit_MCP_Ability_Category_Settings extends ConvertKit_MCP_Ability { + + /** + * The taxonomy this ability operates on. + * + * @since 3.4.0 + * + * @var string + */ + const TAXONOMY = 'category'; + + /** + * Returns the operation suffix used in the ability name (e.g. 'get', + * 'update'). + * + * @since 3.4.0 + * + * @return string + */ + abstract protected function get_operation(); + + /** + * Returns the ability name. + * + * @since 3.4.0 + * + * @return string + */ + public function get_name() { + + return 'kit/category-settings-' . $this->get_operation(); + + } + + /** + * Only permit an ability to be executed if the current user can edit + * the given category term. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return bool|WP_Error + */ + public function permission_callback( $input ) { + + // Get Term ID. + $term_id = isset( $input['term_id'] ) ? absint( $input['term_id'] ) : 0; + + // Bail if no Term ID is provided. + if ( ! $term_id ) { + return new WP_Error( + 'convertkit_mcp_missing_term_id', + __( 'A term_id is required.', 'convertkit' ) + ); + } + + // Bail if the current user cannot edit this term. + if ( ! current_user_can( 'edit_term', $term_id ) ) { + return new WP_Error( + 'convertkit_mcp_cannot_edit_term', + __( 'You do not have permission to edit this category.', 'convertkit' ) + ); + } + + return true; + + } + + /** + * Validates that the given term exists and is in the `category` taxonomy. + * + * Returned as a shared helper for both verb subclasses' execute_callback. + * + * @since 3.4.0 + * + * @param int $term_id Term ID. + * @return true|WP_Error + */ + protected function validate_term( $term_id ) { + + $term = get_term( $term_id ); + + // Bail if the term does not exist. + if ( ! $term || is_wp_error( $term ) ) { + return new WP_Error( + 'convertkit_mcp_term_not_found', + sprintf( + /* translators: %d: Term ID. */ + __( 'Term %d does not exist.', 'convertkit' ), + $term_id + ) + ); + } + + // Bail if the term is not in the `category` taxonomy. + if ( $term->taxonomy !== self::TAXONOMY ) { + return new WP_Error( + 'convertkit_mcp_term_wrong_taxonomy', + sprintf( + /* translators: 1: Term ID, 2: Actual taxonomy, 3: Expected taxonomy. */ + __( 'Term %1$d is in the "%2$s" taxonomy; this ability only supports the "%3$s" taxonomy.', 'convertkit' ), + $term_id, + $term->taxonomy, + self::TAXONOMY + ) + ); + } + + return true; + + } + + /** + * Returns the JSON Schema properties that describe the two Kit category + * settings, shared by both the input and output schemas. + * + * @since 3.4.0 + * + * @return array + */ + protected function get_settings_schema_properties() { + + return array( + 'form' => array( + 'type' => 'integer', + 'description' => __( 'Form to display for Posts assigned to this Category. `-1` = use the Plugin Default Form; `0` = display no form; any other positive integer is a specific Kit Form ID.', 'convertkit' ), + 'minimum' => -1, + ), + 'form_position' => array( + 'type' => 'string', + 'description' => __( 'Where the Form displays on the Category archive page. Empty string uses the Plugin default position; `before` displays it before the post list; `after` displays it after.', 'convertkit' ), + 'enum' => array( '', 'before', 'after' ), + ), + ); + + } + + /** + * Returns the JSON Schema for the ability's output. + * + * Shared by get and update so a caller can chain update -> confirm. + * + * @since 3.4.0 + * + * @return array + */ + public function get_output_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'term_id', 'form', 'form_position' ), + 'properties' => array_merge( + array( + 'term_id' => array( + 'type' => 'integer', + 'description' => __( 'The Category (term) ID.', 'convertkit' ), + ), + ), + $this->get_settings_schema_properties() + ), + ); + + } + +} diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index f79ab3be0..7be6e074a 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -99,6 +99,11 @@ public function __construct() { // settings group. add_filter( 'convertkit_abilities', array( $this, 'register_post_settings_abilities' ) ); + // Register the per-Category Kit settings get / update abilities. + // These operate on the `_wp_convertkit_term_meta` term meta (form, + // form_position) for the WordPress `category` taxonomy. + add_filter( 'convertkit_abilities', array( $this, 'register_category_settings_abilities' ) ); + // Register the MCP server. add_action( 'mcp_adapter_init', array( $this, 'register_mcp_server' ) ); @@ -155,6 +160,25 @@ public function register_post_settings_abilities( $abilities ) { } + /** + * Appends the per-Category Kit settings abilities to the convertkit_abilities + * filter, so they are registered with the Abilities API and exposed via + * the MCP server. + * + * @since 3.4.0 + * + * @param array $abilities Abilities to register. + * @return array + */ + public function register_category_settings_abilities( $abilities ) { + + $abilities['kit/category-settings-get'] = new ConvertKit_MCP_Ability_Category_Settings_Get(); + $abilities['kit/category-settings-update'] = new ConvertKit_MCP_Ability_Category_Settings_Update(); + + return $abilities; + + } + /** * Appends the resource-list abilities (Forms, Tags, Landing Pages, * Products) to the convertkit_abilities filter, so they are registered diff --git a/tests/Integration/MCPCategorySettingsGetTest.php b/tests/Integration/MCPCategorySettingsGetTest.php new file mode 100644 index 000000000..3e9da9afb --- /dev/null +++ b/tests/Integration/MCPCategorySettingsGetTest.php @@ -0,0 +1,237 @@ +assertArrayHasKey(self::ABILITY_NAME, $abilities); + $this->assertInstanceOf(\ConvertKit_MCP_Ability_Category_Settings_Get::class, $abilities[ self::ABILITY_NAME ]); + } + + /** + * Test that permission_callback() rejects an input with no term_id. + * + * @since 3.4.0 + */ + public function testPermissionCallbackRejectsMissingTermId() + { + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->permission_callback([]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_missing_term_id', $result->get_error_code()); + } + + /** + * Test that permission_callback() rejects a user who cannot edit the + * given category (Subscriber role has no manage_categories cap). + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutEditTermCapability() + { + $term_id = $this->createCategoryAsAdmin(); + + // Switch to a subscriber. + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->permission_callback([ 'term_id' => $term_id ]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_cannot_edit_term', $result->get_error_code()); + } + + /** + * Test that get returns the default settings when the Category has no + * Kit term meta stored. + * + * @since 3.4.0 + */ + public function testGetReturnsDefaultsWhenNoMetaExists() + { + $term_id = $this->createCategoryAsAdmin(); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'term_id' => $term_id ]); + + $this->assertIsArray($result); + $this->assertSame($term_id, $result['term_id']); + $this->assertSame(0, $result['form']); + $this->assertSame('', $result['form_position']); + } + + /** + * Test that get returns stored Kit settings for a Category that has + * term meta saved. + * + * @since 3.4.0 + */ + public function testGetReturnsStoredSettings() + { + $term_id = $this->createCategoryAsAdmin(); + + update_term_meta( + $term_id, + '_wp_convertkit_term_meta', + [ + 'form' => 123, + 'form_position' => 'before', + ] + ); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'term_id' => $term_id ]); + + $this->assertSame($term_id, $result['term_id']); + $this->assertSame(123, $result['form']); + $this->assertSame('before', $result['form_position']); + } + + /** + * Test that get returns `form_position = after` correctly (round-trips + * both non-empty enum values, not just `before`). + * + * @since 3.4.0 + */ + public function testGetReturnsFormPositionAfter() + { + $term_id = $this->createCategoryAsAdmin(); + + update_term_meta( + $term_id, + '_wp_convertkit_term_meta', + [ + 'form' => -1, + 'form_position' => 'after', + ] + ); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'term_id' => $term_id ]); + + $this->assertSame(-1, $result['form']); + $this->assertSame('after', $result['form_position']); + } + + /** + * Test that get returns a WP_Error when the term is not in the + * `category` taxonomy (e.g. it's a `post_tag`). + * + * @since 3.4.0 + */ + public function testGetReturnsErrorForNonCategoryTerm() + { + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + $tag_id = static::factory()->term->create([ 'taxonomy' => 'post_tag' ]); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'term_id' => $tag_id ]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_term_wrong_taxonomy', $result->get_error_code()); + } + + /** + * Test that get returns a WP_Error when the given term_id does not exist. + * + * @since 3.4.0 + */ + public function testGetReturnsErrorForNonExistentTerm() + { + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'term_id' => 999999 ]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_term_not_found', $result->get_error_code()); + } + + /** + * Helper: creates an administrator user, switches to them, and returns + * a new Category term ID. + * + * @since 3.4.0 + * + * @return int + */ + private function createCategoryAsAdmin() + { + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + return static::factory()->term->create([ 'taxonomy' => 'category' ]); + } +} diff --git a/tests/Integration/MCPCategorySettingsUpdateTest.php b/tests/Integration/MCPCategorySettingsUpdateTest.php new file mode 100644 index 000000000..2f350f88d --- /dev/null +++ b/tests/Integration/MCPCategorySettingsUpdateTest.php @@ -0,0 +1,308 @@ +assertArrayHasKey(self::ABILITY_NAME, $abilities); + $this->assertInstanceOf(\ConvertKit_MCP_Ability_Category_Settings_Update::class, $abilities[ self::ABILITY_NAME ]); + } + + /** + * Test that update writes both settings and returns the post-save state. + * + * @since 3.4.0 + */ + public function testUpdateWritesBothSettings() + { + $term_id = $this->createCategoryAsAdmin(); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'term_id' => $term_id, + 'form' => 123, + 'form_position' => 'before', + ] + ); + + $this->assertIsArray($result); + $this->assertSame($term_id, $result['term_id']); + $this->assertSame(123, $result['form']); + $this->assertSame('before', $result['form_position']); + + // Confirm persisted to the DB. + $stored = get_term_meta($term_id, '_wp_convertkit_term_meta', true); + $this->assertSame(123, $stored['form']); + $this->assertSame('before', $stored['form_position']); + } + + /** + * Test that a partial update writes only the provided key and preserves + * the other stored setting. Verifies ConvertKit_Term::save()'s internal + * merge behaviour is honoured. + * + * @since 3.4.0 + */ + public function testUpdatePartialUpdatePreservesOtherKey() + { + $term_id = $this->createCategoryAsAdmin(); + + // Seed existing settings. + update_term_meta( + $term_id, + '_wp_convertkit_term_meta', + [ + 'form' => 111, + 'form_position' => 'after', + ] + ); + + $abilities = convertkit_get_abilities(); + + // Update only the form. + $result = $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'term_id' => $term_id, + 'form' => 999, + ] + ); + + $this->assertSame(999, $result['form']); + $this->assertSame('after', $result['form_position']); + } + + /** + * Test that update rejects unknown keys in the input. + * + * @since 3.4.0 + */ + public function testUpdateRejectsUnknownKeys() + { + $term_id = $this->createCategoryAsAdmin(); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'term_id' => $term_id, + 'form' => 123, + 'not_a_field' => 'garbage', + ] + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_category_settings_unknown_keys', $result->get_error_code()); + } + + /** + * Test that update rejects a form_position value outside the enum + * (must be '', 'before' or 'after'). + * + * @since 3.4.0 + */ + public function testUpdateRejectsInvalidFormPosition() + { + $term_id = $this->createCategoryAsAdmin(); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'term_id' => $term_id, + 'form_position' => 'sideways', + ] + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Test that update rejects a form value below the schema minimum + * (schema allows -1 and up). + * + * @since 3.4.0 + */ + public function testUpdateRejectsInvalidFormValue() + { + $term_id = $this->createCategoryAsAdmin(); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'term_id' => $term_id, + 'form' => -99, + ] + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Test that update rejects a call with only term_id and no settings. + * + * @since 3.4.0 + */ + public function testUpdateRejectsWhenNoSettingsProvided() + { + $term_id = $this->createCategoryAsAdmin(); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'term_id' => $term_id ]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_category_settings_no_input', $result->get_error_code()); + } + + /** + * Test that update rejects a term that isn't in the `category` taxonomy. + * + * @since 3.4.0 + */ + public function testUpdateRejectsNonCategoryTerm() + { + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + $tag_id = static::factory()->term->create([ 'taxonomy' => 'post_tag' ]); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'term_id' => $tag_id, + 'form' => 123, + ] + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_term_wrong_taxonomy', $result->get_error_code()); + } + + /** + * Test that update returns a WP_Error when the given term_id does not exist. + * + * @since 3.4.0 + */ + public function testUpdateReturnsErrorForNonExistentTerm() + { + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'term_id' => 999999, + 'form' => 123, + ] + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_term_not_found', $result->get_error_code()); + } + + /** + * Test that update -> get round-trip returns the updated values. + * + * @since 3.4.0 + */ + public function testUpdateThenGetRoundTrip() + { + $term_id = $this->createCategoryAsAdmin(); + + $abilities = convertkit_get_abilities(); + + $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'term_id' => $term_id, + 'form' => 555, + 'form_position' => 'after', + ] + ); + + $get_result = $abilities['kit/category-settings-get']->execute_callback([ 'term_id' => $term_id ]); + + $this->assertSame(555, $get_result['form']); + $this->assertSame('after', $get_result['form_position']); + } + + /** + * Helper: creates an administrator user, switches to them, and returns + * a new Category term ID. + * + * @since 3.4.0 + * + * @return int + */ + private function createCategoryAsAdmin() + { + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + return static::factory()->term->create([ 'taxonomy' => 'category' ]); + } +} diff --git a/wp-convertkit.php b/wp-convertkit.php index 53faac153..b9c174861 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -122,6 +122,9 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings-get.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings-update.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings-get.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings-update.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/plugin-sidebars/class-convertkit-plugin-sidebar.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/plugin-sidebars/class-convertkit-plugin-sidebar-post-settings.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/pre-publish-actions/class-convertkit-pre-publish-action.php';