diff --git a/.distignore b/.distignore index edab61b68..b583cbafc 100644 --- a/.distignore +++ b/.distignore @@ -7,8 +7,6 @@ /node_modules /resources/frontend/css/*.map /tests -/vendor/autoload.php -/vendor/composer /vendor/convertkit/convertkit-wordpress-libraries/.git /vendor/convertkit/convertkit-wordpress-libraries/.github /vendor/convertkit/convertkit-wordpress-libraries/tests diff --git a/.github/workflows/_run-tests.yml b/.github/workflows/_run-tests.yml index 934923c06..0a1c377fb 100644 --- a/.github/workflows/_run-tests.yml +++ b/.github/workflows/_run-tests.yml @@ -165,6 +165,13 @@ jobs: working-directory: ${{ env.ROOT_DIR }} run: wp-cli config set WP_DEBUG true --raw + # Set WP_ENVIRONMENT_TYPE to local. Note: this does NOT enable + # Application Passwords on its own — WordPress core gates the UI + # for those on is_ssl() only. The MU-plugin below handles that. + - name: Set WP_ENVIRONMENT_TYPE + working-directory: ${{ env.ROOT_DIR }} + run: wp-cli config set WP_ENVIRONMENT_TYPE local + # FS_METHOD = direct is required for WP_Filesystem to operate without suppressed PHP fopen() errors that trip up tests. - name: Enable FS_METHOD working-directory: ${{ env.ROOT_DIR }} diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index e9a2359b7..18cd6e66b 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -117,9 +117,12 @@ jobs: tools: cs2pr # Installs wp-browser, Codeception, PHP CodeSniffer and anything else needed to run tests. + # jq is used to remove the wordpress/mcp-adapter package from composer.json, as it requires PHP 7.4+. - name: Run Composer working-directory: ${{ env.PLUGIN_DIR }} - run: composer update + run: | + jq 'del(.require."wordpress/mcp-adapter")' composer.json > composer.json.tmp && mv composer.json.tmp composer.json + composer update # Installs WordPress scripts for CSS and JS Coding Standards. - name: Run npm install diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 86e647ddf..2e6e678a5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,11 +43,13 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-resource-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-review-request.php" + "vendor/wordpress/mcp-adapter/mcp-adapter.php" ) for file in "${files[@]}"; do diff --git a/.scripts/create-plugin-zip.sh b/.scripts/create-plugin-zip.sh index c7d170886..4de10c744 100644 --- a/.scripts/create-plugin-zip.sh +++ b/.scripts/create-plugin-zip.sh @@ -14,11 +14,9 @@ zip -r convertkit.zip . \ -x ".wordpress-org/*" \ -x "log/*" \ -x "tests/*" \ --x "vendor/composer/*" \ -x "vendor/convertkit/convertkit-wordpress-libraries/.github" \ -x "vendor/convertkit/convertkit-wordpress-libraries/tests/*" \ -x "vendor/convertkit/convertkit-wordpress-libraries/composer.json" \ --x "vendor/autoload.php" \ -x "*.distignore" \ -x "*.env.*" \ -x ".gitignore" \ diff --git a/admin/section/class-convertkit-admin-section-base.php b/admin/section/class-convertkit-admin-section-base.php index 650b0321d..79d3e38c0 100644 --- a/admin/section/class-convertkit-admin-section-base.php +++ b/admin/section/class-convertkit-admin-section-base.php @@ -47,7 +47,7 @@ abstract class ConvertKit_Admin_Section_Base { * * @since 1.9.6 * - * @var false|ConvertKit_Settings|ConvertKit_ContactForm7_Settings|ConvertKit_Wishlist_Settings|ConvertKit_Settings_Restrict_Content|ConvertKit_Settings_Broadcasts|ConvertKit_Forminator_Settings + * @var false|ConvertKit_Settings|ConvertKit_ContactForm7_Settings|ConvertKit_Wishlist_Settings|ConvertKit_Settings_Restrict_Content|ConvertKit_Settings_Broadcasts|ConvertKit_Forminator_Settings|ConvertKit_Settings_MCP */ public $settings; diff --git a/admin/section/class-convertkit-admin-section-broadcasts.php b/admin/section/class-convertkit-admin-section-broadcasts.php index 387c0f7bd..b3e57cbbb 100644 --- a/admin/section/class-convertkit-admin-section-broadcasts.php +++ b/admin/section/class-convertkit-admin-section-broadcasts.php @@ -28,8 +28,8 @@ public function __construct() { $this->settings_key = $this->settings::SETTINGS_NAME; // Define the programmatic name, Title and Tab Text. - $this->name = 'broadcasts'; - $this->title = __( 'Broadcasts', 'convertkit' ); + $this->name = $this->settings->get_name(); + $this->title = $this->settings->get_title(); $this->tab_text = __( 'Broadcasts', 'convertkit' ); // Identify that this is beta functionality. diff --git a/admin/section/class-convertkit-admin-section-general.php b/admin/section/class-convertkit-admin-section-general.php index dc10bbb5b..66f3fe59f 100644 --- a/admin/section/class-convertkit-admin-section-general.php +++ b/admin/section/class-convertkit-admin-section-general.php @@ -53,8 +53,8 @@ public function __construct() { $this->settings_key = $this->settings::SETTINGS_NAME; // Define the programmatic name, Title and Tab Text. - $this->name = 'general'; - $this->title = __( 'General Settings', 'convertkit' ); + $this->name = $this->settings->get_name(); + $this->title = $this->settings->get_title(); $this->tab_text = __( 'General', 'convertkit' ); // Define settings sections. diff --git a/admin/section/class-convertkit-admin-section-mcp.php b/admin/section/class-convertkit-admin-section-mcp.php new file mode 100644 index 000000000..daf2c91a8 --- /dev/null +++ b/admin/section/class-convertkit-admin-section-mcp.php @@ -0,0 +1,516 @@ + Kit > MCP. + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_Admin_Section_MCP extends ConvertKit_Admin_Section_Base { + + /** + * The authorization header to display on screen. + * + * @since 3.4.0 + * + * @var bool|string + */ + private $authorization_header = false; + + /** + * Constructor. + * + * @since 3.4.0 + */ + public function __construct() { + + // Define the class that reads/writes settings. + $this->settings = new ConvertKit_Settings_MCP(); + + // Define the settings key. + $this->settings_key = $this->settings::SETTINGS_NAME; + + // Define the programmatic name, Title and Tab Text. + $this->name = 'mcp'; + $this->title = __( 'MCP', 'convertkit' ); + $this->tab_text = __( 'MCP', 'convertkit' ); + + // Identify that this is beta functionality. + $this->is_beta = true; + + // Define settings sections. + $this->settings_sections = array( + 'general' => array( + 'title' => $this->title, + 'callback' => array( $this, 'print_section_info' ), + 'wrap' => true, + ), + ); + + $this->maybe_generate_authentication_header(); + $this->maybe_revoke_application_password(); + + // Register and maybe output notices for this settings screen, and the Intercom messenger. + if ( $this->on_settings_screen( $this->name ) ) { + add_action( 'convertkit_settings_base_render_before', array( $this, 'maybe_output_notices' ) ); + } + + // Enqueue scripts and CSS. + add_action( 'convertkit_admin_settings_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); + + parent::__construct(); + + } + + /** + * Generates the authentication header to display on screen, if the user + * has just created an Application Password. + * + * @since 3.4.0 + */ + private function maybe_generate_authentication_header() { + + // Bail if we're not on the settings screen. + if ( ! $this->on_settings_screen( $this->name ) ) { + return; + } + + // Bail if nonce verification fails. + if ( ! isset( $_REQUEST['_convertkit_settings_mcp_create_application_password'] ) ) { + return; + } + if ( ! wp_verify_nonce( sanitize_key( $_REQUEST['_convertkit_settings_mcp_create_application_password'] ), 'convertkit-mcp-create-application-password' ) ) { + return; + } + + // Bail if the user login and password are not included in the request. + if ( ! isset( $_REQUEST['user_login'] ) || ! isset( $_REQUEST['password'] ) ) { + return; + } + + // Build the authorization header to display on screen. + $user_login = sanitize_text_field( wp_unslash( $_REQUEST['user_login'] ) ); + $password = sanitize_text_field( wp_unslash( $_REQUEST['password'] ) ); + $this->authorization_header = base64_encode( $user_login . ':' . $password ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + + } + + /** + * Revokes the Application Password, if the user clicked the Revoke Application Password button. + * + * @since 3.4.0 + */ + private function maybe_revoke_application_password() { + + // Bail if we're not on the settings screen. + if ( ! $this->on_settings_screen( $this->name ) ) { + return; + } + + // Bail if nonce verification fails. + if ( ! isset( $_REQUEST['_convertkit_settings_mcp_revoke_application_password'] ) ) { + return; + } + if ( ! wp_verify_nonce( sanitize_key( $_REQUEST['_convertkit_settings_mcp_revoke_application_password'] ), 'convertkit-mcp-revoke-application-password' ) ) { + return; + } + + // Get the Application Password UUID. + $application_password_uuid = $this->get_application_password_uuid(); + + // Bail if no Application Password UUID exists. + if ( ! $application_password_uuid ) { + return; + } + + // Revoke the Application Password. + $result = WP_Application_Passwords::delete_application_password( get_current_user_id(), $application_password_uuid ); + if ( is_wp_error( $result ) ) { + $this->output_error( $result->get_error_message() ); + return; + } + + // Reload the settings screen. + wp_safe_redirect( $this->get_settings_url() ); + exit(); + + } + + /** + * Enqueues scripts for the Settings > MCP screen. + * + * @since 3.4.0 + * + * @param string $section Settings section / tab (general|tools|restrict-content|broadcasts|mcp). + */ + public function enqueue_scripts( $section ) { + + // Bail if we're not on the MCP section. + if ( $section !== $this->name ) { + return; + } + + // Enqueue JS. + wp_enqueue_script( 'convertkit-admin-settings-conditional-display', CONVERTKIT_PLUGIN_URL . 'resources/backend/js/settings-conditional-display.js', array( 'jquery' ), CONVERTKIT_PLUGIN_VERSION, true ); + + } + + /** + * Registers settings fields for this section. + * + * @since 3.4.0 + */ + public function register_fields() { + + // Enable. + add_settings_field( + 'enabled', + __( 'Enable MCP Server', 'convertkit' ), + array( $this, 'enabled_callback' ), + $this->settings_key, + $this->name, + array( + 'name' => 'enabled', + 'label_for' => 'enabled', + 'label' => __( 'When enabled, allows AI clients to connect to the Kit Plugin using MCP.', 'convertkit' ), + 'description' => sprintf( + '%s
%s', + __( 'MCP server URL:', 'convertkit' ), + esc_url( ConvertKit_MCP::get_server_url() ) + ), + ) + ); + + // Bail if MCP is not enabled — none of the connect UI applies. + if ( ! $this->settings->enabled() ) { + return; + } + + // If an Application Password exists for this Plugin, display the instructions and revoke section. + if ( $this->get_application_password_uuid() ) { + add_settings_field( + 'connect', + __( 'Connection', 'convertkit' ), + array( $this, 'instructions_disconnect_callback' ), + $this->settings_key, + $this->name + ); + } else { + add_settings_field( + 'connect', + __( 'Connection', 'convertkit' ), + array( $this, 'connect_callback' ), + $this->settings_key, + $this->name + ); + } + + } + + /** + * Prints help info for this section + * + * @since 3.4.0 + */ + public function print_section_info() { + + ?> + +

+ output_checkbox_field( + $args['name'], + 'on', + $this->settings->enabled(), + $args['label'], + $args['description'], + array( 'convertkit-conditional-display' ) + ); + + } + + /** + * Renders the Connect a client setting, to allow the user to generate an Application Password + * for this Plugin which is used to connect AI clients to the MCP Server. + * + * @since 3.4.0 + */ + public function connect_callback() { + + // Build the WordPress authorize-application.php URL. + // See: https://developer.wordpress.org/advanced-administration/security/application-passwords/. + // We don't use add_query_arg(), as rawurlencode() is needed for authorize-application.php's JS to work correctly. + $authorize_url = admin_url( 'authorize-application.php' ) + . '?app_name=' . rawurlencode( CONVERTKIT_MCP_APP_NAME ) + . '&success_url=' . rawurlencode( + $this->get_settings_url( + array( + '_convertkit_settings_mcp_create_application_password' => wp_create_nonce( 'convertkit-mcp-create-application-password' ), + ) + ) + ) + . '&reject_url=' . rawurlencode( $this->get_settings_url() ); + ?> +

+ +

+

+ + + +

+ get_settings_url( array( '_convertkit_settings_mcp_revoke_application_password' => wp_create_nonce( 'convertkit-mcp-revoke-application-password' ) ) ); + + // Fetch query parameters to build the Basic auth header. + if ( $this->authorization_header ) { + ?> +

+ + Basic authorization_header ); ?> +

+

+ +

+ +

+ +
+ +

+ +

+ +

+ + authorization_header + ? 'Basic ' . $this->authorization_header + : __( 'Your base64 encoded username and application password', 'convertkit' ); + + // Claude desktop / Cline JSON. + // + // The Authorization header value is inlined (not passed via the + // `env` block + `${VAR}` substitution as the mcp-remote docs + // suggest), because mcp-remote's variable substitution is unreliable + // with Basic auth values that contain `$` characters in their + // base64 payload — the substitution silently leaves the literal + // `${KIT_AUTH}` string in place, producing 401s. + $claude_desktop_config = wp_json_encode( + array( + 'mcpServers' => array( + 'kit-wordpress' => array( + 'command' => 'npx', + 'args' => array( + '-y', + 'mcp-remote', + $server_url, + '--header', + 'Authorization: ' . $auth_header, + ), + ), + ), + ), + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES + ); + + // Cursor JSON. + $cursor_config = wp_json_encode( + array( + 'mcpServers' => array( + 'kit-wordpress' => array( + 'url' => $server_url, + 'headers' => array( + 'Authorization' => $auth_header, + ), + ), + ), + ), + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES + ); + ?> + +

+

+ claude_desktop_config.json' + ); + ?> +
+ macOS: ~/Library/Application Support/Claude/claude_desktop_config.json +
+ Windows: %APPDATA%\Claude\claude_desktop_config.json +

+
+ +

+

+ +
+ + + +

+ +

+

+ ~/.cursor/mcp.json' + ); + ?> +

+
+ +

+

+ +

+

+ + +

+

+ + +

+ '_wp_convertkit_settings', + 'tab' => $this->name, + ), + $query_args + ), + admin_url( 'options-general.php' ) + ); + + } + + /** + * Finds the UUID of the most recently-created Application Password for the + * currently logged in user + * + * @since 3.4.0 + * + * @return bool|string + */ + private function get_application_password_uuid() { + + // Get the user's Application Passwords. + $passwords = WP_Application_Passwords::get_user_application_passwords( get_current_user_id() ); + + // Return false if no Application Passwords exist. + if ( empty( $passwords ) ) { + return false; + } + + // Iterate through the Application Passwords and return the password that matches the app name. + foreach ( $passwords as $password ) { + if ( $password['name'] === CONVERTKIT_MCP_APP_NAME ) { + return $password['uuid']; + } + } + + return false; + + } + +} + +// Bootstrap. +add_filter( + 'convertkit_admin_settings_register_sections', + function ( $sections ) { + + // Don't register the MCP section if the Abilities API is not available (WordPress < 6.9). + if ( ! function_exists( 'wp_register_ability' ) ) { + return $sections; + } + + // Don't register the MCP section if PHP 7.4+ is not installed. + if ( version_compare( PHP_VERSION, '7.4', '<' ) ) { + return $sections; + } + + $sections['mcp'] = new ConvertKit_Admin_Section_MCP(); + return $sections; + + } +); diff --git a/composer.json b/composer.json index 77993ea04..851ce53ed 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,8 @@ "type": "project", "license": "GPLv3", "require": { - "convertkit/convertkit-wordpress-libraries": "2.1.7" + "convertkit/convertkit-wordpress-libraries": "2.1.7", + "wordpress/mcp-adapter": "^0.5.0" }, "require-dev": { "php-webdriver/webdriver": "^1.0", diff --git a/includes/blocks/class-convertkit-block-broadcasts.php b/includes/blocks/class-convertkit-block-broadcasts.php index d41ba18fb..60db77639 100644 --- a/includes/blocks/class-convertkit-block-broadcasts.php +++ b/includes/blocks/class-convertkit-block-broadcasts.php @@ -27,6 +27,9 @@ public function __construct() { // Register this as a Gutenberg block in the ConvertKit Plugin. add_filter( 'convertkit_blocks', array( $this, 'register' ) ); + // Register this block's MCP abilities. + add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) ); + // Enqueue scripts and styles for this Gutenberg Block in the editor and frontend views. add_action( 'convertkit_gutenberg_enqueue_scripts_editor_and_frontend', array( $this, 'enqueue_scripts' ) ); add_action( 'convertkit_gutenberg_enqueue_styles_editor_and_frontend', array( $this, 'enqueue_styles' ) ); @@ -171,6 +174,19 @@ public function get_title() { } + /** + * Returns this block's plural title. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title_plural() { + + return __( 'Kit Broadcasts', 'convertkit' ); + + } + /** * Returns this block's icon. * diff --git a/includes/blocks/class-convertkit-block-form-trigger.php b/includes/blocks/class-convertkit-block-form-trigger.php index 4bec4aa50..4acbdd3f4 100644 --- a/includes/blocks/class-convertkit-block-form-trigger.php +++ b/includes/blocks/class-convertkit-block-form-trigger.php @@ -27,6 +27,9 @@ public function __construct() { // Register this as a Gutenberg block in the ConvertKit Plugin. add_filter( 'convertkit_blocks', array( $this, 'register' ) ); + // Register this block's MCP abilities. + add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) ); + // Enqueue scripts and styles for this Gutenberg Block in the editor and frontend views. add_action( 'convertkit_gutenberg_enqueue_styles_editor_and_frontend', array( $this, 'enqueue_styles' ) ); @@ -73,6 +76,19 @@ public function get_title() { } + /** + * Returns this block's plural title. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title_plural() { + + return __( 'Kit Form Triggers', 'convertkit' ); + + } + /** * Returns this block's icon. * diff --git a/includes/blocks/class-convertkit-block-form.php b/includes/blocks/class-convertkit-block-form.php index f5e121ba8..7a7db6e73 100644 --- a/includes/blocks/class-convertkit-block-form.php +++ b/includes/blocks/class-convertkit-block-form.php @@ -27,6 +27,9 @@ public function __construct() { // Register this as a Gutenberg block in the ConvertKit Plugin. add_filter( 'convertkit_blocks', array( $this, 'register' ) ); + // Register this block's MCP abilities. + add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) ); + // Enqueue scripts for this Gutenberg Block in the editor view. add_action( 'convertkit_gutenberg_enqueue_scripts', array( $this, 'enqueue_scripts_editor' ) ); @@ -101,6 +104,19 @@ public function get_title() { } + /** + * Returns this block's plural title. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title_plural() { + + return __( 'Kit Forms', 'convertkit' ); + + } + /** * Returns this block's icon. * diff --git a/includes/blocks/class-convertkit-block-product.php b/includes/blocks/class-convertkit-block-product.php index bea8b12a5..aaf522f81 100644 --- a/includes/blocks/class-convertkit-block-product.php +++ b/includes/blocks/class-convertkit-block-product.php @@ -27,6 +27,9 @@ public function __construct() { // Register this as a Gutenberg block in the ConvertKit Plugin. add_filter( 'convertkit_blocks', array( $this, 'register' ) ); + // Register this block's MCP abilities. + add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) ); + // Enqueue scripts and styles for this Gutenberg Block in the editor and frontend views. add_action( 'convertkit_gutenberg_enqueue_scripts_editor_and_frontend', array( $this, 'enqueue_scripts' ) ); add_action( 'convertkit_gutenberg_enqueue_styles_editor_and_frontend', array( $this, 'enqueue_styles' ) ); @@ -95,6 +98,19 @@ public function get_title() { } + /** + * Returns this block's plural title. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title_plural() { + + return __( 'Kit Products', 'convertkit' ); + + } + /** * Returns this block's icon. * diff --git a/includes/blocks/class-convertkit-block.php b/includes/blocks/class-convertkit-block.php index 12a82447e..35d78d32f 100644 --- a/includes/blocks/class-convertkit-block.php +++ b/includes/blocks/class-convertkit-block.php @@ -55,6 +55,28 @@ public function register( $blocks ) { } + /** + * Registers this block's MCP abilities. + * + * @since 3.4.0 + * + * @param array $abilities Abilities to Register. + * @return array + */ + public function register_abilities( $abilities ) { + + return array_merge( + $abilities, + array( + 'kit/' . $this->get_name() . '-list' => new ConvertKit_MCP_Ability_Content_List( $this ), + 'kit/' . $this->get_name() . '-insert' => new ConvertKit_MCP_Ability_Content_Insert( $this ), + 'kit/' . $this->get_name() . '-update' => new ConvertKit_MCP_Ability_Content_Update( $this ), + 'kit/' . $this->get_name() . '-delete' => new ConvertKit_MCP_Ability_Content_Delete( $this ), + ) + ); + + } + /** * Returns this block's programmatic name, excluding the convertkit- prefix. * @@ -83,6 +105,19 @@ public function get_title() { } + /** + * Returns this block's plural title. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title_plural() { + + return ''; + + } + /** * Returns this block's icon. * diff --git a/includes/blocks/helpers/class-convertkit-block-post-helper.php b/includes/blocks/helpers/class-convertkit-block-post-helper.php new file mode 100644 index 000000000..7d25b57b8 --- /dev/null +++ b/includes/blocks/helpers/class-convertkit-block-post-helper.php @@ -0,0 +1,310 @@ +post_content ); + $found = array(); + + $occurrence_index = 0; + + foreach ( $blocks as $block ) { + if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $block_name ) { + continue; + } + + $found[] = array( + 'occurrence_index' => (int) $occurrence_index, + 'attrs' => $block['attrs'], + ); + + ++$occurrence_index; + } + + return $found; + + } + + /** + * Inserts a new block into the Post's content at the specified position. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param string $block_name Programmatic Block Name. + * @param array $attrs Block Attributes. + * @param string $position One of 'prepend', 'append', 'index'. + * @param int $index Zero-based top-level block index; only used when $position is 'index'. + * @return WP_Error|array + */ + public static function insert( $post_id, $block_name, $attrs, $position = 'append', $index = 0 ) { + + // If the index is negative, bail. + if ( $position === 'index' && (int) $index < 0 ) { + return new WP_Error( + 'convertkit_block_post_helper_invalid_index', + sprintf( + /* translators: %d: index */ + __( 'The supplied index (%d) must be zero or a positive integer.', 'convertkit' ), + (int) $index + ) + ); + } + + // Get Post. + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_helper_insert_block_post_not_found', + /* translators: %d: Post ID */ + sprintf( __( 'No Post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + // Parse blocks. + $blocks = parse_blocks( $post->post_content ); + + // Build the new block to insert. + $new_block = array( + 'blockName' => $block_name, + 'attrs' => (array) $attrs, + 'innerBlocks' => array(), + 'innerHTML' => '', + 'innerContent' => array(), + ); + + // Resolve $position into a concrete zero-based splice point in the + // top-level block array. + switch ( $position ) { + case 'prepend': + $insert_at = 0; + break; + + case 'index': + $insert_at = max( 0, min( (int) $index, count( $blocks ) ) ); + break; + + case 'append': + default: + $insert_at = count( $blocks ); + break; + } + + // Splice in the new block. + array_splice( $blocks, $insert_at, 0, array( $new_block ) ); + + // Determine the occurrence index of the newly inserted block, by + // counting how many blocks of the same name precede it. + $occurrence_index = 0; + for ( $i = 0; $i < $insert_at; $i++ ) { + if ( isset( $blocks[ $i ]['blockName'] ) && $blocks[ $i ]['blockName'] === $block_name ) { + ++$occurrence_index; + } + } + + // Update Post. + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + // Bail if the update failed. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the occurrence index of the newly inserted block. + return array( + 'post_id' => $post_id, + 'occurrence_index' => $occurrence_index, + ); + + } + + /** + * Updates the attributes of an existing block in the Post's content. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param string $block_name Programmatic Block Name. + * @param int $occurrence_index Position to update block. + * @param array $attrs Block Attributes. + * @return WP_Error|array + */ + public static function update( $post_id, $block_name, $occurrence_index, $attrs ) { + + // Get Post. + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_helper_update_block_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No Post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + // Parse blocks. + $blocks = parse_blocks( $post->post_content ); + $block_index = 0; + $matched = false; + + foreach ( $blocks as $key => $block ) { + // Skip if the block name does not match. + if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $block_name ) { + continue; + } + + // Update the block if the occurrence index matches. + if ( $block_index === (int) $occurrence_index ) { + $blocks[ $key ]['attrs'] = array_merge( (array) $block['attrs'], (array) $attrs ); + $matched = true; + break; + } + + ++$block_index; + } + + // Bail if the block was not found. + if ( ! $matched ) { + return new WP_Error( + 'convertkit_block_post_helper_occurrence_not_found', + /* translators: 1: block name, 2: occurrence index, 3: post ID */ + sprintf( __( 'No occurrence #%2$d of block %1$s found in post %3$d.', 'convertkit' ), $block_name, (int) $occurrence_index, $post_id ) + ); + } + + // Update Post. + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + // Bail if the update failed. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the occurrence index of the block that was updated. + return array( + 'post_id' => $post_id, + 'occurrence_index' => (int) $occurrence_index, + ); + + } + + /** + * Deletes a specific block from the Post's content. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param string $block_name Programmatic Block Name. + * @param int $occurrence_index Zero-based index among this block's occurrences in the post. + * @return WP_Error|array + */ + public static function delete( $post_id, $block_name, $occurrence_index ) { + + // Get Post. + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_helper_update_block_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No Post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + // Parse blocks. + $blocks = parse_blocks( $post->post_content ); + $block_index = 0; + $matched = false; + + foreach ( $blocks as $key => $block ) { + // Skip if the block name does not match. + if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $block_name ) { + continue; + } + + // Delete the block if the occurrence index matches. + if ( $block_index === (int) $occurrence_index ) { + unset( $blocks[ $key ] ); + $blocks = array_values( $blocks ); + $matched = true; + break; + } + + ++$block_index; + } + + // Bail if the block was not found. + if ( ! $matched ) { + return new WP_Error( + 'convertkit_block_post_helper_occurrence_not_found', + /* translators: 1: block name, 2: occurrence index, 3: post ID */ + sprintf( __( 'No occurrence #%2$d of block %1$s found in post %3$d.', 'convertkit' ), $block_name, (int) $occurrence_index, $post_id ) + ); + } + + // Update Post. + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + // Bail if the update failed. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the occurrence index of the block that was deleted. + return array( + 'post_id' => $post_id, + 'occurrence_index' => (int) $occurrence_index, + ); + + } + +} diff --git a/includes/blocks/helpers/class-convertkit-content-post-helper.php b/includes/blocks/helpers/class-convertkit-content-post-helper.php new file mode 100644 index 000000000..762ea5edd --- /dev/null +++ b/includes/blocks/helpers/class-convertkit-content-post-helper.php @@ -0,0 +1,307 @@ +post_content ) ? 'block' : 'shortcode'; + + } + + /** + * Returns the human-readable name of the page builder used to build the + * given Post, or false if no supported page builder is detected. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @return string|false + */ + private static function detect_page_builder( $post_id ) { + + // Elementor stores its content in the _elementor_data post meta key, + // and flags edited posts via _elementor_edit_mode. + if ( 'builder' === get_post_meta( $post_id, '_elementor_edit_mode', true ) ) { + return 'Elementor'; + } + + /** + * Filters the detected page builder for a Post. + * + * Return a non-empty string (the page builder's name) to mark the Post + * as built with an unsupported page builder, causing the Content MCP + * abilities to return an error rather than writing to post_content. + * + * @since 3.4.0 + * + * @param string|false $page_builder Detected page builder name, or false. + * @param int $post_id Post ID. + */ + return apply_filters( 'convertkit_content_post_helper_detect_page_builder', false, $post_id ); + + } + + /** + * Returns a WP_Error for an unrecognised content mechanism. Acts as a + * defensive fallback; detect_mechanism() should only ever return a known + * mechanism or a WP_Error. + * + * @since 3.4.0 + * + * @param string $mechanism The unrecognised mechanism. + * @return WP_Error + */ + private static function unsupported_mechanism_error( $mechanism ) { + + return new WP_Error( + 'convertkit_content_post_helper_unsupported_mechanism', + sprintf( + /* translators: %s: mechanism identifier */ + __( 'Unsupported content mechanism: %s.', 'convertkit' ), + $mechanism + ) + ); + + } + +} diff --git a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php new file mode 100644 index 000000000..f13fddce9 --- /dev/null +++ b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php @@ -0,0 +1,557 @@ +post_content, $shortcode_tag ); + $found = array(); + + foreach ( $matches as $occurrence_index => $match ) { + $found[] = array( + // Zero-based index of this occurrence among occurrences of + // this shortcode in the post. + 'occurrence_index' => (int) $occurrence_index, + 'attrs' => self::parse_attrs( $match ), + ); + } + + // If no shortcodes found, return false. + if ( empty( $found ) ) { + return false; + } + + return $found; + + } + + /** + * Inserts a new shortcode into the Post's content at the specified + * position. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param string $shortcode_tag Programmatic Shortcode Tag. + * @param array $attrs Shortcode Attributes. + * @param string $position One of 'prepend', 'append', 'index'. + * @param int $index Zero-based top-level element index; only used when $position is 'index'. + * @return WP_Error|array + */ + public static function insert( $post_id, $shortcode_tag, $attrs, $position = 'append', $index = 0 ) { + + // If the index is negative, bail. + if ( $position === 'index' && (int) $index < 0 ) { + return new WP_Error( + 'convertkit_shortcode_post_helper_invalid_index', + sprintf( + /* translators: %d: index */ + __( 'The supplied index (%d) must be zero or a positive integer.', 'convertkit' ), + (int) $index + ) + ); + } + + // Get Post. + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_shortcode_post_helper_insert_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + // Build the shortcode string to insert. + $shortcode = self::build_shortcode( $shortcode_tag, $attrs ); + $content = $post->post_content; + + // Determine the byte offset of the start of each top-level element. + $starts = self::get_element_starts( $content ); + + // Resolve $position into a concrete byte offset within the content. + switch ( $position ) { + case 'prepend': + $insert_at = 0; + break; + + case 'index': + // Insert before the Nth top-level element. If no elements + // exist, or the index is equal to / beyond count(), append + // after all existing content — mirroring how array_splice() + // treats an index equal to the array length. + if ( empty( $starts ) || (int) $index >= count( $starts ) ) { + $insert_at = strlen( $content ); + } else { + $insert_at = $starts[ (int) $index ]; + } + break; + + case 'append': + default: + $insert_at = strlen( $content ); + break; + } + + // Determine the occurrence index the new shortcode will have, by + // counting how many existing occurrences of the same shortcode start + // before the insertion offset. + $occurrence_index = 0; + foreach ( self::match_shortcodes( $content, $shortcode_tag ) as $match ) { + if ( $match['offset'] < $insert_at ) { + ++$occurrence_index; + } + } + + // Splice the shortcode into the content at the resolved offset, + // wrapped in blank lines so it sits as its own top-level element. + // All other content is left byte-for-byte unchanged. + $snippet = self::pad_snippet( $shortcode, $content, $insert_at ); + $content = substr_replace( $content, $snippet, $insert_at, 0 ); + + // Update Post. + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => $content, + ), + true + ); + + // Bail if the update failed. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the occurrence index of the newly inserted shortcode. + return array( + 'post_id' => $post_id, + 'occurrence_index' => $occurrence_index, + ); + + } + + /** + * Updates the attributes of an existing shortcode in the Post's content. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param string $shortcode_tag Programmatic Shortcode Tag. + * @param int $occurrence_index Zero-based occurrence index to update. + * @param array $attrs Shortcode Attributes. + * @return WP_Error|array + */ + public static function update( $post_id, $shortcode_tag, $occurrence_index, $attrs ) { + + // Get Post. + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_shortcode_post_helper_update_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + // Match all occurrences of the shortcode. + $matches = self::match_shortcodes( $post->post_content, $shortcode_tag ); + + // Bail if the requested occurrence does not exist. + if ( ! isset( $matches[ (int) $occurrence_index ] ) ) { + return new WP_Error( + 'convertkit_shortcode_post_helper_occurrence_not_found', + sprintf( + /* translators: 1: shortcode tag, 2: occurrence index, 3: post ID */ + __( 'No occurrence #%2$d of shortcode %1$s found in post %3$d.', 'convertkit' ), + $shortcode_tag, + (int) $occurrence_index, + $post_id + ) + ); + } + + // Build the replacement shortcode, merging new attributes over existing. + $match = $matches[ (int) $occurrence_index ]; + $merged_attrs = array_merge( self::parse_attrs( $match ), (array) $attrs ); + $replacement = self::build_shortcode( $shortcode_tag, $merged_attrs ); + + // Replace the matched shortcode text with the rebuilt shortcode. + $content = self::replace_match( $post->post_content, $match, $replacement ); + + // Update Post. + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => $content, + ), + true + ); + + // Bail if the update failed. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the occurrence index that was updated. + return array( + 'post_id' => $post_id, + 'occurrence_index' => (int) $occurrence_index, + ); + + } + + /** + * Deletes a specific shortcode from the Post's content. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param string $shortcode_tag Programmatic Shortcode Tag. + * @param int $occurrence_index Zero-based occurrence index to delete. + * @return WP_Error|array + */ + public static function delete( $post_id, $shortcode_tag, $occurrence_index ) { + + // Get Post. + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_shortcode_post_helper_delete_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + // Match all occurrences of the shortcode. + $matches = self::match_shortcodes( $post->post_content, $shortcode_tag ); + + // Bail if the requested occurrence does not exist. + if ( ! isset( $matches[ (int) $occurrence_index ] ) ) { + return new WP_Error( + 'convertkit_shortcode_post_helper_occurrence_not_found', + sprintf( + /* translators: 1: shortcode tag, 2: occurrence index, 3: post ID */ + __( 'No occurrence #%2$d of shortcode %1$s found in post %3$d.', 'convertkit' ), + $shortcode_tag, + (int) $occurrence_index, + $post_id + ) + ); + } + + // Remove the matched shortcode text from the content. + $content = self::replace_match( $post->post_content, $matches[ (int) $occurrence_index ], '' ); + + // Update Post. + $result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => $content, + ), + true + ); + + // Bail if the update failed. + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return the occurrence index that was deleted. + return array( + 'post_id' => $post_id, + 'occurrence_index' => (int) $occurrence_index, + ); + + } + + /** + * Returns all matches of the given shortcode tag within the content, in + * document order. + * + * Each match is an array of: + * - 'text' The full matched shortcode string (e.g. `[convertkit_form form="1"]`). + * - 'offset' Its byte offset within the content. + * - 'atts' The raw attribute string only (e.g. `form="1"`), suitable for + * passing directly to shortcode_parse_atts(). + * + * @since 3.4.0 + * + * @param string $content Post content. + * @param string $shortcode_tag Programmatic Shortcode Tag. + * @return array + */ + private static function match_shortcodes( $content, $shortcode_tag ) { + + // Build a shortcode regex scoped to this single tag. + $pattern = get_shortcode_regex( array( $shortcode_tag ) ); + + // Bail if there are no matches. + if ( ! preg_match_all( '/' . $pattern . '/', $content, $matches, PREG_OFFSET_CAPTURE ) ) { + return array(); + } + + // Build array of shortcode matches. + $found = array(); + foreach ( $matches[0] as $i => $match ) { + $found[] = array( + 'text' => $match[0], + 'offset' => (int) $match[1], + 'atts' => isset( $matches[3][ $i ][0] ) ? trim( (string) $matches[3][ $i ][0] ) : '', + ); + } + + return $found; + + } + + /** + * Parses the attributes of a single matched shortcode into a key/value + * array. + * + * @since 3.4.0 + * + * @param array $shortcode A match from match_shortcodes(). + * @return array + */ + private static function parse_attrs( $shortcode ) { + + // Parse the raw attribute string (e.g. `form="1"`). shortcode_parse_atts() + // expects only the attributes, without the surrounding brackets or tag name. + $attrs = shortcode_parse_atts( $shortcode['atts'] ); + + // Discard any positional (non-string keyed) attributes, keeping only + // named attributes. + foreach ( array_keys( $attrs ) as $key ) { + if ( ! is_string( $key ) ) { + unset( $attrs[ $key ] ); + } + } + + return $attrs; + + } + + /** + * Builds a self-closing shortcode string from a tag and attributes. + * + * @since 3.4.0 + * + * @param string $shortcode_tag Programmatic Shortcode Tag. + * @param array $attrs Shortcode Attributes. + * @return string + */ + private static function build_shortcode( $shortcode_tag, $attrs ) { + + $shortcode = '[' . $shortcode_tag; + + foreach ( (array) $attrs as $key => $value ) { + // Skip empty attribute names. + if ( ! is_string( $key ) || '' === $key ) { + continue; + } + + $shortcode .= sprintf( ' %s="%s"', $key, esc_attr( (string) $value ) ); + } + + $shortcode .= ']'; + + return $shortcode; + + } + + /** + * Replaces a single matched shortcode occurrence with the replacement + * string. + * + * @since 3.4.0 + * + * @param string $content Post content. + * @param array $atts A match from match_shortcodes(). + * @param string $replacement Replacement string (empty string to delete). + * @return string + */ + private static function replace_match( $content, $atts, $replacement ) { + + return substr_replace( + $content, + $replacement, + $atts['offset'], + strlen( $atts['text'] ) + ); + + } + + /** + * Wraps a shortcode snippet in blank-line padding so that, once inserted + * at the given offset, it sits as its own top-level element. + * + * @since 3.4.0 + * + * @param string $shortcode The shortcode string to insert. + * @param string $content The content the shortcode is being inserted into. + * @param int $offset Byte offset within $content the shortcode will be inserted at. + * @return string + */ + private static function pad_snippet( $shortcode, $content, $offset ) { + + // Determine the text immediately before and after the insertion point. + $before = substr( $content, 0, $offset ); + $after = substr( $content, $offset ); + + // Add a leading blank line unless the shortcode is at the start of the + // content, or already preceded by a blank line. + $lead = ( $before === '' || preg_match( '/\R\R\s*$/', $before ) ) ? '' : "\n\n"; + + // Add a trailing blank line unless the shortcode is at the end of the + // content, or already followed by a blank line. + $trail = ( $after === '' || preg_match( '/^\s*\R\R/', $after ) ) ? '' : "\n\n"; + + return $lead . $shortcode . $trail; + + } + + /** + * Returns the byte offset of the start of each top-level element in the + * content, in document order. + * + * Uses WP_HTML_Tag_Processor (WP 6.2+) for nesting-aware structure, paired + * with a regex for the byte offsets the tag processor does not expose. + * Falls back to regex alone on older WordPress versions. + * + * @since 3.4.0 + * + * @param string $content Post content. + * @return array + */ + private static function get_element_starts( $content ) { + + if ( trim( (string) $content ) === '' ) { + return array(); + } + + // Candidate offsets, one per regex-matched element-level opener. + $pattern = '/<(' . self::ELEMENT_LEVEL_TAGS . ')\b[^>]*>.*?<\/\1>/is'; + if ( ! preg_match_all( $pattern, $content, $matches, PREG_OFFSET_CAPTURE ) ) { + return array(); + } + + // Fallback for WP < 6.2: regex offsets verbatim, no nesting awareness. + if ( ! class_exists( 'WP_HTML_Tag_Processor' ) ) { + $starts = array(); + foreach ( $matches[0] as $match ) { + $starts[] = (int) $match[1]; + } + return $starts; + } + + // Per-tag queue of regex offsets in document order. + $queues = array(); + foreach ( $matches[1] as $i => $tag_match ) { + $queues[ strtoupper( $tag_match[0] ) ][] = (int) $matches[0][ $i ][1]; + } + + // Walk with depth tracking; record offsets only for depth-zero openers. + $processor = new WP_HTML_Tag_Processor( $content ); + $starts = array(); + $depth = 0; + $element_level_tags = array_flip( explode( '|', strtoupper( self::ELEMENT_LEVEL_TAGS ) ) ); + + while ( $processor->next_tag( array( 'tag_closers' => 'visit' ) ) ) { + $tag = $processor->get_tag(); + + if ( ! isset( $element_level_tags[ $tag ] ) ) { + continue; + } + + if ( $processor->is_tag_closer() ) { + if ( $depth > 0 ) { + --$depth; + } + continue; + } + + $offset = array_shift( $queues[ $tag ] ); + + if ( $depth === 0 ) { + $starts[] = $offset; + } + + if ( $tag !== 'HR' ) { + ++$depth; + } + } + + // Treat blank line separated text as paragraphs, matching the logic in wpautop(). + $opener_prefix = '/^<(?:' . self::ELEMENT_LEVEL_TAGS . ')\b/i'; + $offset = 0; + foreach ( preg_split( '/(\R\R+)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE ) as $i => $chunk ) { + // Odd indices are the delimiters captured by PREG_SPLIT_DELIM_CAPTURE. + if ( $i % 2 === 1 ) { + $offset += strlen( $chunk ); + continue; + } + + $trimmed = trim( $chunk ); + if ( $trimmed !== '' && ! preg_match( $opener_prefix, $trimmed ) ) { + $starts[] = $offset + ( strlen( $chunk ) - strlen( ltrim( $chunk ) ) ); + } + + $offset += strlen( $chunk ); + } + + sort( $starts, SORT_NUMERIC ); + + return $starts; + + } + +} diff --git a/includes/class-convertkit-settings-broadcasts.php b/includes/class-convertkit-settings-broadcasts.php index 9d743a0d2..d0edd87cc 100644 --- a/includes/class-convertkit-settings-broadcasts.php +++ b/includes/class-convertkit-settings-broadcasts.php @@ -190,6 +190,108 @@ public function no_styles() { } + /** + * Returns this settings group's programmatic name. + * + * @since 3.4.0 + * + * @return string + */ + public function get_name() { + + return 'broadcasts'; + + } + + /** + * Returns the title of this settings group. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title() { + + return __( 'Broadcasts Settings', 'convertkit' ); + + } + + /** + * Returns the keys in this settings group that hold credentials or other + * sensitive values. + * + * @since 3.4.0 + * + * @return string[] + */ + public function get_secret_keys() { + + return array(); + + } + + /** + * Returns the JSON Schema describing this settings group, in the shape + * stored by save() / returned by get(), excluding secret keys. + * + * @since 3.4.0 + * + * @return array + */ + public function get_schema() { + + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'enabled' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether importing Broadcasts from Kit to WordPress Posts is enabled.', 'convertkit' ), + ), + 'author_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'WordPress User ID to assign as the Post author when importing Broadcasts.', 'convertkit' ), + ), + 'post_status' => array( + 'type' => 'string', + 'description' => __( 'WordPress Post status to assign to Posts created from imported Broadcasts (e.g. publish, draft).', 'convertkit' ), + ), + 'category_id' => array( + 'type' => array( 'integer', 'string' ), + 'description' => __( 'WordPress Category ID to assign to Posts created from imported Broadcasts. Blank for none.', 'convertkit' ), + ), + 'import_thumbnail' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether to import the Broadcast thumbnail as the Post\'s Featured Image.', 'convertkit' ), + ), + 'import_images' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether to import images referenced in the Broadcast\'s content into the WordPress Media Library.', 'convertkit' ), + ), + 'published_at_min_date' => array( + 'type' => 'string', + 'format' => 'date', + 'description' => __( 'Earliest published_at date (YYYY-MM-DD) of Broadcasts to import.', 'convertkit' ), + ), + 'enabled_export' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether exporting WordPress Posts to Kit Broadcasts is enabled.', 'convertkit' ), + ), + 'no_styles' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether inline styles on imported Broadcast content should be stripped.', 'convertkit' ), + ), + ), + ); + + } + /** * The default settings, used when the ConvertKit Broadcasts Settings haven't been saved * e.g. on a new installation. diff --git a/includes/class-convertkit-settings-mcp.php b/includes/class-convertkit-settings-mcp.php new file mode 100644 index 000000000..9e58b50f1 --- /dev/null +++ b/includes/class-convertkit-settings-mcp.php @@ -0,0 +1,121 @@ +settings = $this->get_defaults(); + } else { + $this->settings = array_merge( $this->get_defaults(), $settings ); + } + + } + + /** + * Returns Plugin settings. + * + * @since 3.4.0 + * + * @return array + */ + public function get() { + + return $this->settings; + + } + + /** + * Returns whether the MCP server is enabled. + * + * @since 3.4.0 + * + * @return bool + */ + public function enabled() { + + return ( $this->settings['enabled'] === 'on' ? true : false ); + + } + + /** + * The default settings, used when the ConvertKit MCP Settings haven't been saved + * e.g. on a new installation. + * + * @since 2.1.0 + * + * @return array + */ + public function get_defaults() { + + $defaults = array( + 'enabled' => '', // blank|on. + ); + + /** + * The default settings, used when the ConvertKit MCP Settings haven't been saved + * e.g. on a new installation. + * + * @since 3.4.0 + * + * @param array $defaults Default settings. + */ + $defaults = apply_filters( 'convertkit_settings_mcp_get_defaults', $defaults ); + + return $defaults; + + } + + /** + * Saves the given array of settings to the WordPress options table. + * + * @since 3.4.0 + * + * @param array $settings Settings. + */ + public function save( $settings ) { + + update_option( self::SETTINGS_NAME, array_merge( $this->get(), $settings ) ); + + } + +} diff --git a/includes/class-convertkit-settings-restrict-content.php b/includes/class-convertkit-settings-restrict-content.php index 2330d8a4d..8ae12ed89 100644 --- a/includes/class-convertkit-settings-restrict-content.php +++ b/includes/class-convertkit-settings-restrict-content.php @@ -102,6 +102,138 @@ public function get_by_key( $key ) { } + /** + * Returns this settings group's programmatic name. + * + * @since 3.4.0 + * + * @return string + */ + public function get_name() { + + return 'restrict-content'; + + } + + /** + * Returns the title of this settings group. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title() { + + return __( 'Member Content Settings', 'convertkit' ); + + } + + /** + * Returns the keys in this settings group that hold credentials or other + * sensitive values. + * + * Member Content settings hold no secrets; returned for interface + * consistency. + * + * @since 3.4.0 + * + * @return string[] + */ + public function get_secret_keys() { + + return array(); + + } + + /** + * Returns the JSON Schema describing this settings group, in the shape + * stored by save() / returned by get(), excluding secret keys. + * + * @since 3.4.0 + * + * @return array + */ + public function get_schema() { + + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'permit_crawlers' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether search engine crawlers are permitted to index Member Content.', 'convertkit' ), + ), + 'no_access_text_form' => array( + 'type' => 'string', + 'description' => __( 'Message shown to a visitor without access when content is restricted by Form.', 'convertkit' ), + ), + 'subscribe_heading' => array( + 'type' => 'string', + 'description' => __( 'Heading shown above the subscribe call-to-action when content is restricted by Product.', 'convertkit' ), + ), + 'subscribe_text' => array( + 'type' => 'string', + 'description' => __( 'Body text shown alongside the subscribe call-to-action when content is restricted by Product.', 'convertkit' ), + ), + 'no_access_text' => array( + 'type' => 'string', + 'description' => __( 'Message shown to a visitor without access when content is restricted by Product.', 'convertkit' ), + ), + 'subscribe_heading_tag' => array( + 'type' => 'string', + 'description' => __( 'Heading shown above the subscribe call-to-action when content is restricted by Tag.', 'convertkit' ), + ), + 'subscribe_text_tag' => array( + 'type' => 'string', + 'description' => __( 'Body text shown alongside the subscribe call-to-action when content is restricted by Tag.', 'convertkit' ), + ), + 'require_tag_login' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether visitors must log in by email to access Member Content restricted by Tag.', 'convertkit' ), + ), + 'no_access_text_tag' => array( + 'type' => 'string', + 'description' => __( 'Message shown to a visitor without access when content is restricted by Tag.', 'convertkit' ), + ), + 'subscribe_button_label' => array( + 'type' => 'string', + 'description' => __( 'Label for the Subscribe button.', 'convertkit' ), + ), + 'email_text' => array( + 'type' => 'string', + 'description' => __( 'Body text shown above the email log-in form.', 'convertkit' ), + ), + 'email_button_label' => array( + 'type' => 'string', + 'description' => __( 'Label for the email log-in button.', 'convertkit' ), + ), + 'email_heading' => array( + 'type' => 'string', + 'description' => __( 'Heading shown above the email log-in form.', 'convertkit' ), + ), + 'email_description_text' => array( + 'type' => 'string', + 'description' => __( 'Description shown beneath the email log-in heading.', 'convertkit' ), + ), + 'email_check_heading' => array( + 'type' => 'string', + 'description' => __( 'Heading shown after the visitor requests a magic log-in code.', 'convertkit' ), + ), + 'email_check_text' => array( + 'type' => 'string', + 'description' => __( 'Body text shown after the visitor requests a magic log-in code.', 'convertkit' ), + ), + 'container_css_classes' => array( + 'type' => 'string', + 'description' => __( 'Additional CSS classes appended to the Restrict Content container element.', 'convertkit' ), + ), + ), + ); + + } + /** * The default settings, used when the ConvertKit Restrict Content Settings haven't been saved * e.g. on a new installation. diff --git a/includes/class-convertkit-settings.php b/includes/class-convertkit-settings.php index 13c5234b2..3df8953c6 100644 --- a/includes/class-convertkit-settings.php +++ b/includes/class-convertkit-settings.php @@ -568,6 +568,170 @@ public function usage_tracking() { } + /** + * Returns this settings group's programmatic name. + * + * @since 3.4.0 + * + * @return string + */ + public function get_name() { + + return 'general'; + + } + + /** + * Returns the title of this settings group. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title() { + + return __( 'General Settings', 'convertkit' ); + + } + + /** + * Returns the keys in this settings group that hold credentials or other + * sensitive values. + * + * @since 3.4.0 + * + * @return string[] + */ + public function get_secret_keys() { + + return array( + 'access_token', + 'refresh_token', + 'token_expires', + 'api_key', + 'api_secret', + 'recaptcha_secret_key', + ); + + } + + /** + * Returns the JSON Schema describing this settings group, in the shape + * stored by save() / returned by get(), excluding secret keys. + * + * @since 3.4.0 + * + * @return array + */ + public function get_schema() { + + $properties = array( + 'non_inline_form' => array( + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + 'description' => __( 'IDs of non-inline Forms to display site-wide.', 'convertkit' ), + ), + 'non_inline_form_honor_none_setting' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether the site-wide non-inline Form honors a per-Page / per-Post "None" Form setting.', 'convertkit' ), + ), + 'non_inline_form_limit_per_session' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether to limit non-inline Form display to once per session.', 'convertkit' ), + ), + 'recaptcha_site_key' => array( + 'type' => 'string', + 'description' => __( 'Google reCAPTCHA v3 site key.', 'convertkit' ), + ), + 'recaptcha_minimum_score' => array( + 'type' => 'number', + 'minimum' => 0, + 'maximum' => 1, + 'description' => __( 'Minimum Google reCAPTCHA v3 score (0.0 - 1.0) below which a request is treated as spam.', 'convertkit' ), + ), + 'debug' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether debug logging is enabled.', 'convertkit' ), + ), + 'no_scripts' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether the Plugin\'s frontend JavaScript is disabled.', 'convertkit' ), + ), + 'no_css' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether the Plugin\'s frontend CSS is disabled.', 'convertkit' ), + ), + 'no_add_new_button' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether the "Add New" button for Landing Pages / Member Content is hidden in the WordPress Admin.', 'convertkit' ), + ), + 'usage_tracking' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether anonymous usage tracking is enabled.', 'convertkit' ), + ), + ); + + // Per-post-type Default Form settings, mirroring get_defaults(). + foreach ( convertkit_get_supported_post_types() as $post_type ) { + $properties[ $post_type . '_form' ] = array( + 'type' => 'integer', + 'minimum' => -1, + 'description' => sprintf( + /* translators: Post type slug. */ + __( 'Default Form ID to display on %s. `-1` = Plugin Default; `0` = None; positive integer = specific Kit Form ID.', 'convertkit' ), + $post_type + ), + ); + + $properties[ $post_type . '_form_position' ] = array( + 'type' => 'string', + 'enum' => array( 'before_content', 'after_content', 'before_after_content', 'after_element' ), + 'description' => sprintf( + /* translators: Post type slug. */ + __( 'Where the Default Form displays relative to the %s content.', 'convertkit' ), + $post_type + ), + ); + + $properties[ $post_type . '_form_position_element' ] = array( + 'type' => 'string', + 'enum' => array( 'p', 'h2', 'h3', 'h4', 'h5', 'h6', 'img' ), + 'description' => sprintf( + /* translators: 1: Post type slug, 2: Post type slug. */ + __( 'HTML element after which to display the Default Form on %1$s. Only used when `%2$s_form_position` is `after_element`.', 'convertkit' ), + $post_type, + $post_type + ), + ); + + $properties[ $post_type . '_form_position_element_index' ] = array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 999, + 'description' => sprintf( + /* translators: 1: Post type slug, 2: Post type slug. */ + __( 'Nth occurrence of the element after which to display the Default Form on %1$s. Only used when `%2$s_form_position` is `after_element`.', 'convertkit' ), + $post_type, + $post_type + ), + ); + } + + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => $properties, + ); + + } + /** * The default settings, used when the ConvertKit Plugin Settings haven't been saved * e.g. on a new installation. diff --git a/includes/class-wp-convertkit.php b/includes/class-wp-convertkit.php index 371ab59d0..47de6d700 100644 --- a/includes/class-wp-convertkit.php +++ b/includes/class-wp-convertkit.php @@ -64,6 +64,7 @@ public function initialize() { $this->initialize_cli_cron(); $this->initialize_frontend(); $this->initialize_global(); + $this->initialize_mcp(); } @@ -202,13 +203,14 @@ private function initialize_global() { $this->classes['broadcasts_importer'] = new ConvertKit_Broadcasts_Importer(); $this->classes['elementor'] = new ConvertKit_Elementor(); $this->classes['gutenberg'] = new ConvertKit_Gutenberg(); - $this->classes['media_library'] = new ConvertKit_Media_Library(); - $this->classes['output_restrict_content'] = new ConvertKit_Output_Restrict_Content(); - $this->classes['restrict_content_cache'] = new ConvertKit_Restrict_Content_Cache(); - $this->classes['review_request'] = new ConvertKit_Review_Request( 'Kit', 'convertkit', CONVERTKIT_PLUGIN_PATH ); - $this->classes['preview_output'] = new ConvertKit_Preview_Output(); - $this->classes['setup'] = new ConvertKit_Setup(); - $this->classes['shortcodes'] = new ConvertKit_Shortcodes(); + $this->classes['mcp'] = new ConvertKit_MCP(); + $this->classes['media_library'] = new ConvertKit_Media_Library(); + $this->classes['output_restrict_content'] = new ConvertKit_Output_Restrict_Content(); + $this->classes['restrict_content_cache'] = new ConvertKit_Restrict_Content_Cache(); + $this->classes['review_request'] = new ConvertKit_Review_Request( 'Kit', 'convertkit', CONVERTKIT_PLUGIN_PATH ); + $this->classes['preview_output'] = new ConvertKit_Preview_Output(); + $this->classes['setup'] = new ConvertKit_Setup(); + $this->classes['shortcodes'] = new ConvertKit_Shortcodes(); /** * Initialize integration classes for the frontend web site. @@ -219,6 +221,49 @@ private function initialize_global() { } + /** + * Initializes the MCP server if enabled in the Plugin's settings. + * + * @since 3.4.0 + */ + public function initialize_mcp() { + + // Bail if the MCP server is not enabled. + $settings = new ConvertKit_Settings_MCP(); + if ( ! $settings->enabled() ) { + return; + } + + // Bail if the Abilities API is unavailable (WordPress < 6.9). + if ( ! function_exists( 'wp_register_ability' ) ) { + return; + } + + // Bail if PHP 7.4+ is not installed, as this is required for the MCP Adapter classes. + if ( version_compare( PHP_VERSION, '7.4', '<' ) ) { + return; + } + + // Bail if the WordPress MCP Adapter autoloader is missing. + if ( ! file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) ) { + return; + } + + // Load MCP Adapter. + require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php'; + + // Bail if the MCP Adapter class doesn't exist - something went wrong with the autoloader. + if ( ! class_exists( 'WP\\MCP\\Core\\McpAdapter' ) ) { + return; + } + + // Bootstrap the MCP Adapter, per WordPress/mcp-adapter's recommended + // integration pattern. + // @see https://github.com/WordPress/mcp-adapter#using-mcp-adapter-in-your-plugin. + \WP\MCP\Core\McpAdapter::instance(); + + } + /** * Runs the Plugin's initialization and update routines, which checks if * the Plugin has just been updated to a newer version, diff --git a/includes/functions.php b/includes/functions.php index 42037ad75..590c340f1 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -307,6 +307,30 @@ function convertkit_get_form_importers() { } +/** + * Helper method to get registered abilities. + * + * @since 3.4.0 + * + * @return array Abilities. + */ +function convertkit_get_abilities() { + + $abilities = array(); + + /** + * Registers abilities for the Kit Plugin. + * + * @since 3.4.0 + * + * @param array $abilities Abilities. + */ + $abilities = apply_filters( 'convertkit_abilities', $abilities ); + + return $abilities; + +} + /** * Helper method to return the Plugin Settings Link * 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/abilities/content/class-convertkit-mcp-ability-content-delete.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php new file mode 100644 index 000000000..c0081e9e9 --- /dev/null +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php @@ -0,0 +1,134 @@ +-delete` (e.g. `kit/form-delete`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Content_Delete extends ConvertKit_MCP_Ability_Content { + + /** + * Sets whether the ability is destructive. + * + * @since 3.4.0 + * + * @var bool + */ + private $destructive = true; // @phpstan-ignore-line + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + public function get_verb() { + + return 'delete'; + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + public function get_label() { + + return sprintf( + /* translators: %s: block title */ + __( 'Delete Existing %s from a Post, Page or Custom Post', 'convertkit' ), + $this->block->get_title() + ); + + } + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + public function get_description() { + + return sprintf( + /* translators: Block Name */ + __( 'Removes an existing %s from a Post, Page or Custom Post using the supplied zero-based occurrence index.', 'convertkit' ), + $this->block->get_title_plural() + ); + + } + + /** + * Returns the ability's input JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'post_id', 'occurrence_index' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'ID of the post containing the element.', 'convertkit' ), + ), + 'occurrence_index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'The zero-based occurrence index of the element to delete.', 'convertkit' ), + ), + ), + ); + + } + + /** + * Executes the ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + // Get Post ID. + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + + // Bail if no Post ID is provided. + if ( ! $post_id ) { + return new WP_Error( + 'convertkit_mcp_missing_post_id', + __( 'A post_id is required.', 'convertkit' ) + ); + } + + // Get occurrence index. + $occurrence_index = isset( $input['occurrence_index'] ) ? (int) $input['occurrence_index'] : 0; + + // Delete the element from the post. + return ConvertKit_Content_Post_Helper::delete( $post_id, $this->block->get_name(), $occurrence_index ); + + } + +} diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php new file mode 100644 index 000000000..d705ec571 --- /dev/null +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php @@ -0,0 +1,138 @@ +-insert` (e.g. `kit/form-insert`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Content_Insert extends ConvertKit_MCP_Ability_Content { + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + public function get_verb() { + + return 'insert'; + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + public function get_label() { + + return sprintf( + /* translators: %s: block title */ + __( 'Insert %s into a Page, Post or Custom Post', 'convertkit' ), + $this->block->get_title() + ); + + } + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + public function get_description() { + + return sprintf( + /* translators: 1: block full name e.g. convertkit/form, 2: block title */ + __( 'Inserts a new %s in a Page, Post or Custom Post\'s content. The element can be appended (default), prepended, or inserted relative to an existing element using a zero-based index.', 'convertkit' ), + $this->block->get_title_plural() + ); + + } + + /** + * Returns the ability's input JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'post_id', 'attrs' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Page / Post / Custom Post Type ID to insert the element into.', 'convertkit' ), + ), + 'position' => array( + 'type' => 'string', + 'enum' => array( 'append', 'prepend', 'index' ), + 'default' => 'append', + 'description' => __( 'Where to insert the new element. "index" requires the "index" property.', 'convertkit' ), + ), + 'index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'When position is "index", the zero-based top-level element index at which to insert the new element.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Element attributes.', 'convertkit' ), + 'properties' => $this->get_input_schema_properties(), + ), + ), + ); + + } + + /** + * Executes the ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + // Get Post ID. + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + + // Bail if no Post ID is provided. + if ( ! $post_id ) { + return new WP_Error( + 'convertkit_mcp_missing_post_id', + __( 'A post_id is required.', 'convertkit' ) + ); + } + + // Get attributes, position and index. + $attrs = isset( $input['attrs'] ) && is_array( $input['attrs'] ) ? $input['attrs'] : array(); + $position = isset( $input['position'] ) ? (string) $input['position'] : 'append'; + $index = isset( $input['index'] ) ? (int) $input['index'] : 0; + + // Insert the element into the post. + return ConvertKit_Content_Post_Helper::insert( $post_id, $this->block->get_name(), $attrs, $position, $index ); + + } + +} diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php new file mode 100644 index 000000000..8f0685d01 --- /dev/null +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php @@ -0,0 +1,188 @@ +-list` (e.g. `kit/form-list`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Content_List extends ConvertKit_MCP_Ability_Content { + + /** + * Sets whether the ability is readonly. + * + * @since 3.4.0 + * + * @var bool + */ + private $readonly = true; // @phpstan-ignore-line + + /** + * Sets whether the ability is idempotent. + * + * @since 3.4.0 + * + * @var bool + */ + private $idempotent = true; // @phpstan-ignore-line + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + public function get_verb() { + + return 'list'; + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + public function get_label() { + + return sprintf( + /* translators: %s: block title */ + __( 'List %s in a Post, Page or Custom Post', 'convertkit' ), + $this->block->get_title_plural() + ); + + } + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + public function get_description() { + + return sprintf( + /* translators: Block Name */ + __( 'Lists every %s in the given Post, Page or Custom Post, including each occurrence\'s zero-based index and current attribute values.', 'convertkit' ), + $this->block->get_title_plural() + ); + + } + + /** + * Returns the ability's input JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'post_id' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'ID of the post to inspect.', 'convertkit' ), + ), + ), + ); + + } + + /** + * Returns the ability's output JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_output_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'post_id', 'count', 'occurrences' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + ), + 'count' => array( + 'type' => 'integer', + 'minimum' => 0, + ), + 'occurrences' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'required' => array( 'occurrence_index', 'attrs' ), + 'properties' => array( + 'occurrence_index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index among this element\'s appearances in the post.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Element attributes for this occurrence.', 'convertkit' ), + ), + ), + ), + ), + ), + ); + + } + + /** + * Executes the ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + // Get Post ID. + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + + // Bail if no Post ID is provided. + if ( ! $post_id ) { + return new WP_Error( + 'convertkit_mcp_missing_post_id', + __( 'A post_id is required.', 'convertkit' ) + ); + } + + // Find element occurrences in post. + $occurrences = ConvertKit_Content_Post_Helper::find( $post_id, $this->block->get_name() ); + if ( is_wp_error( $occurrences ) ) { + return $occurrences; + } + + // Return result. + return array( + 'post_id' => $post_id, + 'count' => count( $occurrences ), + 'occurrences' => $occurrences, + ); + + } + +} diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php new file mode 100644 index 000000000..a614f66c9 --- /dev/null +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php @@ -0,0 +1,140 @@ +-update` (e.g. `kit/form-update`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Content_Update extends ConvertKit_MCP_Ability_Content { + + /** + * Sets whether the ability is idempotent. + * + * @since 3.4.0 + * + * @var bool + */ + private $idempotent = true; // @phpstan-ignore-line + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + public function get_verb() { + + return 'update'; + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + public function get_label() { + + return sprintf( + /* translators: %s: block title */ + __( 'Update Existing %s in a Page, Post or Custom Post', 'convertkit' ), + $this->block->get_title() + ); + + } + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + public function get_description() { + + return sprintf( + /* translators: Block Name */ + __( 'Updates the attributes of an existing %s in a Page, Post or Custom Post. The provided attributes are merged into the existing attributes.', 'convertkit' ), + $this->block->get_title_plural() + ); + + } + + /** + * Returns the ability's input JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'post_id', 'occurrence_index', 'attrs' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'Page / Post / Custom Post Type ID containing the existing element.', 'convertkit' ), + ), + 'occurrence_index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'The zero-based occurrence index of the element to update.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Element attributes to update. Any attributes not provided will be left unchanged.', 'convertkit' ), + 'properties' => $this->get_input_schema_properties(), + ), + ), + ); + + } + + /** + * Executes the ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + // Get Post ID. + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + + // Bail if no Post ID is provided. + if ( ! $post_id ) { + return new WP_Error( + 'convertkit_mcp_missing_post_id', + __( 'A post_id is required.', 'convertkit' ) + ); + } + + // Get attributes and occurrence index. + $attrs = isset( $input['attrs'] ) && is_array( $input['attrs'] ) ? $input['attrs'] : array(); + $occurrence_index = isset( $input['occurrence_index'] ) ? (int) $input['occurrence_index'] : 0; + + // Update the element in the post. + return ConvertKit_Content_Post_Helper::update( $post_id, $this->block->get_name(), $occurrence_index, $attrs ); + + } + +} diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php new file mode 100644 index 000000000..539ef3e36 --- /dev/null +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php @@ -0,0 +1,186 @@ +block = $block; + + } + + /** + * Returns the ability name, derived from the Kit element's name and the verb + * returned by get_verb(). + * + * For example, the Form element's insert ability is named `kit/form-insert`. + * + * @since 3.4.0 + * + * @return string + */ + public function get_name() { + + return 'kit/' . $this->block->get_name() . '-' . $this->get_verb(); + + } + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + abstract public function get_verb(); + + /** + * Only permit an ability to be executed if the current user can edit the given post. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return bool|WP_Error + */ + public function permission_callback( $input ) { + + // Get Post ID. + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + + // Bail if no Post ID is provided. + if ( ! $post_id ) { + return new WP_Error( + 'convertkit_mcp_missing_post_id', + __( 'A post_id is required.', 'convertkit' ) + ); + } + + // Bail if the current user does not have permission to edit the post. + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'convertkit_mcp_cannot_edit_post', + __( 'You do not have permission to edit this post.', 'convertkit' ) + ); + } + + return true; + + } + + /** + * Returns the ability's output JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_output_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'post_id', 'occurrence_index' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'description' => __( 'The Post/Page/Custom Post Type ID.', 'convertkit' ), + ), + 'occurrence_index' => array( + 'type' => 'integer', + 'description' => __( 'The zero-based occurrence index of the Kit element in the post.', 'convertkit' ), + ), + ), + ); + + } + + /** + * Returns JSON Schema properties derived from the block's get_fields(), + * suitable for use as the `attrs` object in an Abilities API input schema. + * + * Used by verb subclasses whose input schema includes an `attrs` object + * (insert, update). + * + * @since 3.4.0 + * + * @return array + */ + protected function get_input_schema_properties() { + + // Define properties. + $properties = array(); + $fields = $this->block->get_fields(); + + foreach ( $fields as $field_name => $field ) { + $properties[ $field_name ] = array( + 'description' => isset( $field['label'] ) ? (string) $field['label'] : '', + 'type' => $this->get_input_schema_property_type( $field ), + ); + } + + return $properties; + + } + + /** + * Returns the JSON Schema type for the given field definition. + * + * @since 3.4.0 + * + * @param array $field Field definition. + * @return string + */ + private function get_input_schema_property_type( $field ) { + + $type = isset( $field['type'] ) ? (string) $field['type'] : 'string'; + + switch ( $type ) { + case 'resource': + case 'text': + case 'color': + case 'select': + return 'string'; + + case 'number': + return 'integer'; + + case 'toggle': + return 'boolean'; + + default: + // Unknown field type — fall back to string. + return 'string'; + } + + } + +} diff --git a/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings-get.php b/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings-get.php new file mode 100644 index 000000000..537b5ed2a --- /dev/null +++ b/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings-get.php @@ -0,0 +1,139 @@ + 'object', + 'required' => array( 'post_id' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'description' => __( 'The Post/Page/Custom Post Type 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 ) { + + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + + // Bail if the Post does not exist. + if ( ! get_post( $post_id ) ) { + return new WP_Error( + 'convertkit_mcp_post_not_found', + sprintf( + /* translators: %d: Post ID. */ + __( 'Post %d does not exist.', 'convertkit' ), + $post_id + ) + ); + } + + // Load the Post's settings. + $post_settings = new ConvertKit_Post( $post_id ); + $settings = $post_settings->get(); + + // Cast values to string so they match the output schema (Post storage + // keeps them as strings, but defense-in-depth for numeric coercion). + return array( + 'post_id' => $post_id, + 'form' => (string) $settings['form'], + 'landing_page' => (string) $settings['landing_page'], + 'tag' => (string) $settings['tag'], + 'restrict_content' => (string) $settings['restrict_content'], + ); + + } + +} diff --git a/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings-update.php b/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings-update.php new file mode 100644 index 000000000..f871549bd --- /dev/null +++ b/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings-update.php @@ -0,0 +1,172 @@ + 'object', + 'required' => array( 'post_id' ), + 'properties' => array_merge( + array( + 'post_id' => array( + 'type' => 'integer', + 'description' => __( 'The Post/Page/Custom Post Type 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 ) { + + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + + // Bail if the Post does not exist. + if ( ! get_post( $post_id ) ) { + return new WP_Error( + 'convertkit_mcp_post_not_found', + sprintf( + /* translators: %d: Post ID. */ + __( 'Post %d does not exist.', 'convertkit' ), + $post_id + ) + ); + } + + // Reject unknown keys. + $properties = $this->get_settings_schema_properties(); + $allowed_keys = array_merge( array( 'post_id' ), array_keys( $properties ) ); + $unknown_keys = array_diff( array_keys( $input ), $allowed_keys ); + if ( ! empty( $unknown_keys ) ) { + return new WP_Error( + 'convertkit_mcp_post_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_post_settings_no_input', + __( 'At least one setting (form, landing_page, tag or restrict_content) must be provided.', 'convertkit' ) + ); + } + + // Merge into the Post's existing settings so this is a partial update. + $post_settings = new ConvertKit_Post( $post_id ); + $merged = array_merge( $post_settings->get(), $validated ); + + // Save. This fires updated_post_meta, which the Restrict Content + // cache class listens for; nothing extra required here. + $post_settings->save( $merged ); + + // Return the post-save state, using the get ability so the shape + // exactly matches kit/post-settings-get. + $get_ability = new ConvertKit_MCP_Ability_Post_Settings_Get(); + return $get_ability->execute_callback( array( 'post_id' => $post_id ) ); + + } + +} diff --git a/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings.php b/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings.php new file mode 100644 index 000000000..599bca61f --- /dev/null +++ b/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings.php @@ -0,0 +1,146 @@ +`. + * + * @package ConvertKit + * @author ConvertKit + */ +abstract class ConvertKit_MCP_Ability_Post_Settings extends ConvertKit_MCP_Ability { + + /** + * 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/post-settings-' . $this->get_operation(); + + } + + /** + * Only permit an ability to be executed if the current user can edit + * the given post. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return bool|WP_Error + */ + public function permission_callback( $input ) { + + // Get Post ID. + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + + // Bail if no Post ID is provided. + if ( ! $post_id ) { + return new WP_Error( + 'convertkit_mcp_missing_post_id', + __( 'A post_id is required.', 'convertkit' ) + ); + } + + // Bail if the current user does not have permission to edit the post. + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'convertkit_mcp_cannot_edit_post', + __( 'You do not have permission to edit this post.', 'convertkit' ) + ); + } + + return true; + + } + + /** + * Returns the JSON Schema properties that describe the four Kit post + * settings, shared by both the input and output schemas. + * + * Values are stored by the Plugin as strings (matching what the metabox + * submits), so the schemas expose them as strings with format constraints. + * + * @since 3.4.0 + * + * @return array + */ + protected function get_settings_schema_properties() { + + return array( + 'form' => array( + 'type' => 'string', + 'description' => __( 'Form to display for the Post. `-1` = use the Plugin Default Form for this Post Type; `0` = display no form; any other positive integer is a specific Kit Form ID.', 'convertkit' ), + 'pattern' => '^(-1|0|[1-9][0-9]*)$', + ), + 'landing_page' => array( + 'type' => 'string', + 'description' => __( 'Kit Landing Page ID to display instead of the Post content. `0` or empty string for none.', 'convertkit' ), + 'pattern' => '^([0-9]+)?$', + ), + 'tag' => array( + 'type' => 'string', + 'description' => __( 'Kit Tag ID to apply when the Post is viewed by a Kit subscriber. `0` or empty string for none.', 'convertkit' ), + 'pattern' => '^([0-9]+)?$', + ), + 'restrict_content' => array( + 'type' => 'string', + 'description' => __( 'Restrict Post content to Kit subscribers. Empty string or `0` for no restriction, or one of `form_`, `tag_`, `product_` to require subscription to that resource.', 'convertkit' ), + 'pattern' => '^$|^0$|^(form|tag|product)_[1-9][0-9]*$', + ), + ); + + } + + /** + * Returns the JSON Schema for the ability's output. + * + * The output shape is the same for get and update: the four settings + * plus the post_id, so a caller can chain update -> confirm without a + * follow-up get. + * + * @since 3.4.0 + * + * @return array + */ + public function get_output_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'post_id', 'form', 'landing_page', 'tag', 'restrict_content' ), + 'properties' => array_merge( + array( + 'post_id' => array( + 'type' => 'integer', + 'description' => __( 'The Post/Page/Custom Post Type ID.', 'convertkit' ), + ), + ), + $this->get_settings_schema_properties() + ), + ); + + } + +} diff --git a/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-forms.php b/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-forms.php new file mode 100644 index 000000000..14c91b206 --- /dev/null +++ b/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-forms.php @@ -0,0 +1,133 @@ + (int) ( $item['id'] ?? 0 ), + 'name' => (string) ( $item['name'] ?? '' ), + 'format' => isset( $item['format'] ) && $item['format'] !== '' ? (string) $item['format'] : 'inline', + ); + + } + + /** + * Returns the JSON Schema for a single Form item, including the `format` + * field added by map_item(). + * + * @since 3.4.0 + * + * @return array + */ + protected function get_item_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'id', 'name', 'format' ), + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'Numeric ID of the Kit Form.', 'convertkit' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'Human-readable name of the Kit Form.', 'convertkit' ), + ), + 'format' => array( + 'type' => 'string', + 'enum' => array( 'inline', 'modal', 'slide in', 'sticky bar' ), + 'description' => __( 'Where and how the Form is displayed. Inline forms render in post content; modal / slide in / sticky bar forms are site-wide overlays triggered elsewhere.', 'convertkit' ), + ), + ), + ); + + } + +} diff --git a/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-landing-pages.php b/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-landing-pages.php new file mode 100644 index 000000000..be0d05def --- /dev/null +++ b/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-landing-pages.php @@ -0,0 +1,73 @@ +get_resource() . '-list'; + + } + + /** + * Returns the resource slug for this ability, used in the ability name + * and as a hint for clients (e.g. `forms`, `tags`, `landing-pages`, + * `products`). + * + * @since 3.4.0 + * + * @return string + */ + abstract protected function get_resource(); + + /** + * Returns the fully-qualified class name of the ConvertKit_Resource_* + * implementation backing this ability. + * + * @since 3.4.0 + * + * @return string + */ + abstract protected function get_resource_class(); + + /** + * Maps a single raw resource item from the resource class' get() method + * into the shape exposed in this ability's output. + * + * The default implementation returns just id and name. Subclasses may + * override to expose additional per-item fields (e.g. Forms includes + * `format`) — output_schema() should be overridden to match. + * + * @since 3.4.0 + * + * @param array $item Raw item from the resource class' get() method. + * @return array + */ + protected function map_item( $item ) { + + return array( + 'id' => (int) ( $item['id'] ?? 0 ), + 'name' => (string) ( $item['name'] ?? '' ), + ); + + } + + /** + * Returns the JSON Schema describing a single item in the output `items` + * array. + * + * Subclasses may override to add per-resource fields. Keep in sync with + * map_item() — both describe the same shape, one in schema form and one + * in PHP. + * + * @since 3.4.0 + * + * @return array + */ + protected function get_item_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'id', 'name' ), + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'Numeric ID of the resource item.', 'convertkit' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'Human-readable name of the resource item.', 'convertkit' ), + ), + ), + ); + + } + + /** + * Permission callback for resource-list abilities. + * + * Listing available Kit resources is permitted for anyone who can edit + * posts — the same capability gate that allows placing a Kit element on + * a post, where these lists are typically used as a lookup. + * + * @since 3.4.0 + * + * @param array $input Ability input (unused). + * @return bool|WP_Error + */ + public function permission_callback( $input ) { + + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'convertkit_mcp_cannot_list_resources', + __( 'You do not have permission to list Kit resources.', 'convertkit' ) + ); + } + + return true; + + } + + /** + * Returns the ability's input JSON Schema. + * + * Resource-list abilities take no input. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema() { + + return array( + 'type' => 'object', + 'properties' => new stdClass(), + ); + + } + + /** + * Returns the ability's output JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_output_schema() { + + return array( + 'type' => 'object', + 'required' => array( 'count', 'items' ), + 'properties' => array( + 'count' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'The number of items returned.', 'convertkit' ), + ), + 'items' => array( + 'type' => 'array', + 'description' => __( 'The resource items.', 'convertkit' ), + 'items' => $this->get_item_schema(), + ), + ), + ); + + } + + /** + * Executes the ability: instantiate the backing resource class, fetch + * its cached items, and return them mapped to this ability's output + * shape. + * + * A "no items" result (e.g. the Plugin has not yet cached this resource + * from the Kit API) is returned as a successful empty list rather than + * an error, so the model can explain the absence to the user. + * + * @since 3.4.0 + * + * @param array $input Ability input (unused). + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + // Instantiate the backing resource class. + $resource_class = $this->get_resource_class(); + if ( ! class_exists( $resource_class ) ) { + return new WP_Error( + 'convertkit_mcp_resource_class_missing', + sprintf( + /* translators: %s: Resource class name */ + __( 'The resource class "%s" does not exist.', 'convertkit' ), + $resource_class + ) + ); + } + + $resource = new $resource_class(); + + // Fetch the items from the resource cache. ConvertKit_Resource::get() + // returns false when nothing has been cached; normalise that to an + // empty array so the output shape is always consistent. + $items = $resource->get(); + if ( ! is_array( $items ) ) { + $items = array(); + } + + // Map each raw item to the ability's output shape. + $mapped = array(); + foreach ( $items as $item ) { + $mapped[] = $this->map_item( $item ); + } + + return array( + 'count' => count( $mapped ), + 'items' => $mapped, + ); + + } + +} diff --git a/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-get.php b/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-get.php new file mode 100644 index 000000000..ba853a375 --- /dev/null +++ b/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-get.php @@ -0,0 +1,148 @@ +-get` (e.g. `kit/settings-general-get`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Settings_Get extends ConvertKit_MCP_Ability_Settings { + + /** + * Sets whether the ability is readonly. + * + * @since 3.4.0 + * + * @var bool + */ + private $readonly = true; // @phpstan-ignore-line + + /** + * Sets whether the ability is idempotent. + * + * @since 3.4.0 + * + * @var bool + */ + private $idempotent = true; // @phpstan-ignore-line + + /** + * Returns the operation suffix used in the ability name. + * + * @since 3.4.0 + * + * @return string + */ + protected function get_operation() { + + return 'get'; + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + public function get_label() { + + return sprintf( + /* translators: %s: Settings Title, e.g. 'General Settings'. */ + __( 'Get Kit Plugin %s', 'convertkit' ), + $this->settings->get_title() + ); + + } + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + public function get_description() { + + return sprintf( + /* translators: %s: Settings Title, e.g. 'General Settings'. */ + __( 'Returns the current values of the Kit Plugin "%s".', 'convertkit' ), + $this->settings->get_title() + ); + + } + + /** + * Returns the ability's input JSON Schema. + * + * Get takes no input. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema() { + + return array( + 'type' => 'object', + 'properties' => new stdClass(), + ); + + } + + /** + * Returns the ability's output JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_output_schema() { + + return $this->get_public_schema(); + + } + + /** + * Executes the ability: returns the current settings, scoped to the keys + * declared in the public schema. + * + * Stored values are sanitised through the property schema before being + * returned, so the response matches the declared types (e.g. a numeric + * setting stored as the string "0.5" is returned as 0.5). + * + * @since 3.4.0 + * + * @param array $input Ability input (unused). + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + $values = $this->settings->get(); + $schema = $this->get_public_schema(); + $result = array(); + + if ( ! isset( $schema['properties'] ) || ! is_array( $schema['properties'] ) ) { + return $result; + } + + foreach ( $schema['properties'] as $key => $property_schema ) { + if ( array_key_exists( $key, $values ) ) { + $result[ $key ] = rest_sanitize_value_from_schema( $values[ $key ], $property_schema, $key ); + } + } + + return $result; + + } + +} diff --git a/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-update.php b/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-update.php new file mode 100644 index 000000000..a8e30438a --- /dev/null +++ b/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-update.php @@ -0,0 +1,182 @@ +-update` (e.g. `kit/settings-general-update`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Settings_Update extends ConvertKit_MCP_Ability_Settings { + + /** + * Sets whether the ability is idempotent. + * + * @since 3.4.0 + * + * @var bool + */ + private $idempotent = true; // @phpstan-ignore-line + + /** + * Returns the operation suffix used in the ability name. + * + * @since 3.4.0 + * + * @return string + */ + protected function get_operation() { + + return 'update'; + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + public function get_label() { + + return sprintf( + /* translators: %s: Settings Title, e.g. 'General Settings'. */ + __( 'Update Kit Plugin %s', 'convertkit' ), + $this->settings->get_title() + ); + + } + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + public function get_description() { + + return sprintf( + /* translators: %s: Settings Title, e.g. 'General Settings'. */ + __( 'Updates one or more values in the Kit Plugin "%s". Only keys declared in the input schema can be updated; secret values (API keys, OAuth tokens) cannot be set via this ability.', 'convertkit' ), + $this->settings->get_title() + ); + + } + + /** + * Returns the ability's input JSON Schema. + * + * Mirrors the settings class's get_schema() with secret keys removed, so + * partial updates are possible (no top-level `required`). + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema() { + + return $this->get_public_schema(); + + } + + /** + * Returns the ability's output JSON Schema. + * + * Returns the same shape as kit/settings--get so a caller can chain + * update → confirm in one round trip. + * + * @since 3.4.0 + * + * @return array + */ + public function get_output_schema() { + + return $this->get_public_schema(); + + } + + /** + * Executes the ability. + * + * Validates the input, rejecting unknown and secret keys + * and saves via the settings class. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + // Bail if no input is provided. + if ( ! count( $input ) ) { + return new WP_Error( + 'convertkit_mcp_settings_invalid_input', + __( 'Input must be an object of settings keys and values.', 'convertkit' ) + ); + } + + // Get the public schema, allowed and secret keys. + $schema = $this->get_public_schema(); + $allowed_keys = array_keys( $schema['properties'] ); + $secret_keys = $this->settings->get_secret_keys(); + + // Bail if any secret keys are provided in the input. + $secret_attempts = array_intersect( array_keys( $input ), $secret_keys ); + if ( ! empty( $secret_attempts ) ) { + return new WP_Error( + 'convertkit_mcp_settings_secret_write', + sprintf( + /* translators: %s: Comma-separated list of secret keys. */ + __( 'The following settings cannot be updated via MCP: %s.', 'convertkit' ), + implode( ', ', $secret_attempts ) + ) + ); + } + + // Bail if any unknown keys are provided in the input. + $unknown_attempts = array_diff( array_keys( $input ), $allowed_keys ); + if ( ! empty( $unknown_attempts ) ) { + return new WP_Error( + 'convertkit_mcp_settings_unknown_keys', + sprintf( + /* translators: %s: Comma-separated list of unknown keys. */ + __( 'The following settings keys are not recognised: %s.', 'convertkit' ), + implode( ', ', $unknown_attempts ) + ) + ); + } + + // Validate each provided value against its declared schema. + $validated = array(); + foreach ( $input as $key => $value ) { + $valid = rest_validate_value_from_schema( $value, $schema['properties'][ $key ], $key ); + + // Bail if the value is invalid. + if ( is_wp_error( $valid ) ) { + return $valid; + } + + $validated[ $key ] = rest_sanitize_value_from_schema( $value, $schema['properties'][ $key ], $key ); + } + + // Save via the settings class so its own sanitisation runs. + $this->settings->save( $validated ); + + // Return the post-save state. + $get_ability = new ConvertKit_MCP_Ability_Settings_Get( $this->settings ); + return $get_ability->execute_callback( array() ); + + } + +} diff --git a/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings.php b/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings.php new file mode 100644 index 000000000..ccc25e700 --- /dev/null +++ b/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings.php @@ -0,0 +1,110 @@ +settings = $settings; + + } + + /** + * Returns the operation suffix used in the ability name (e.g. 'get', + * 'update'). Combined with the settings name to produce the full + * `kit/settings--` name. + * + * @since 3.4.0 + * + * @return string + */ + abstract protected function get_operation(); + + /** + * Returns the ability name, derived from the settings name and operation + * (e.g. `kit/settings-general-get`). + * + * @since 3.4.0 + * + * @return string + */ + public function get_name() { + + return 'kit/settings-' . $this->settings->get_name() . '-' . $this->get_operation(); + + } + + /** + * Permission callback for settings abilities. + * + * Plugin settings are restricted to users who can manage options, matching + * the capability that gates the Plugin's own settings screens. + * + * @since 3.4.0 + * + * @param array $input Ability input (unused). + * @return bool|WP_Error + */ + public function permission_callback( $input ) { + + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'convertkit_mcp_cannot_manage_settings', + __( 'You do not have permission to read or update Kit Plugin settings.', 'convertkit' ) + ); + } + + return true; + + } + + /** + * Returns the ability's input and output JSON schemas. + * + * @since 3.4.0 + * + * @return array + */ + protected function get_public_schema() { + + $schema = $this->settings->get_schema(); + $secret = $this->settings->get_secret_keys(); + + if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { + foreach ( $secret as $key ) { + unset( $schema['properties'][ $key ] ); + } + } + + return $schema; + + } + +} diff --git a/includes/mcp/class-convertkit-mcp-ability.php b/includes/mcp/class-convertkit-mcp-ability.php new file mode 100644 index 000000000..4cbf26ee6 --- /dev/null +++ b/includes/mcp/class-convertkit-mcp-ability.php @@ -0,0 +1,165 @@ + $this->get_label(), + 'description' => $this->get_description(), + 'category' => $this->get_category(), + 'input_schema' => $this->get_input_schema(), + 'output_schema' => $this->get_output_schema(), + 'permission_callback' => array( $this, 'permission_callback' ), + 'execute_callback' => array( $this, 'execute_callback' ), + 'meta' => array( + 'annotations' => $this->get_annotations(), + ), + ); + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + abstract public function get_label(); + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + abstract public function get_description(); + + /** + * Returns the ability's category. + * + * @since 3.4.0 + * + * @return string + */ + public function get_category() { + + return 'kit'; + + } + + /** + * Returns the ability's input JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + abstract public function get_input_schema(); + + /** + * Returns the ability's output JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + abstract public function get_output_schema(); + + /** + * Define the annotations for the ability. + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => $this->readonly, + 'destructive' => $this->destructive, + 'idempotent' => $this->idempotent, + ); + + } + + /** + * Permission callback for this ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return bool|WP_Error + */ + abstract public function permission_callback( $input ); + + /** + * Execute callback for this ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + abstract public function execute_callback( $input ); + +} diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php new file mode 100644 index 000000000..7be6e074a --- /dev/null +++ b/includes/mcp/class-convertkit-mcp.php @@ -0,0 +1,289 @@ +get_name() ] = $get; + $abilities[ $update->get_name() ] = $update; + } + + return $abilities; + + } + + /** + * Appends the per-Post 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_post_settings_abilities( $abilities ) { + + $abilities['kit/post-settings-get'] = new ConvertKit_MCP_Ability_Post_Settings_Get(); + $abilities['kit/post-settings-update'] = new ConvertKit_MCP_Ability_Post_Settings_Update(); + + return $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 + * 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_resource_abilities( $abilities ) { + + return array_merge( + $abilities, + array( + 'kit/forms-list' => new ConvertKit_MCP_Ability_Resource_Forms(), + 'kit/tags-list' => new ConvertKit_MCP_Ability_Resource_Tags(), + 'kit/landing-pages-list' => new ConvertKit_MCP_Ability_Resource_Landing_Pages(), + 'kit/products-list' => new ConvertKit_MCP_Ability_Resource_Products(), + ) + ); + + } + + /** + * Register the 'kit' ability category. + * + * @since 3.4.0 + */ + public function register_abilities_category() { + + wp_register_ability_category( + self::CATEGORY_SLUG, + array( + 'label' => __( 'Kit', 'convertkit' ), + 'description' => __( 'Abilities exposed by the Kit Plugin.', 'convertkit' ), + ) + ); + + } + + /** + * Register abilities with the WordPress Abilities API. + * + * @since 3.4.0 + */ + public function register_abilities() { + + // Get abilities. + $abilities = convertkit_get_abilities(); + + // Bail if no abilities are available. + if ( ! count( $abilities ) ) { + return; + } + + // Iterate through abilities, registering them. + foreach ( $abilities as $ability ) { + + // Skip if this ability is not an instance of ConvertKit_MCP_Ability. + if ( ! ( $ability instanceof ConvertKit_MCP_Ability ) ) { + continue; + } + + // Register ability. + wp_register_ability( $ability->get_name(), $ability->get_ability_args() ); + } + + } + + /** + * Register an MCP server that exposes Kit abilities as MCP tools. + * + * @since 3.4.0 + * + * @param object $adapter The MCP Adapter instance. + * @return void + */ + public function register_mcp_server( $adapter ) { + + // Get abilities. + $abilities = convertkit_get_abilities(); + + // Build array of ability names. + $ability_names = array(); + foreach ( $abilities as $ability ) { + $ability_names[] = $ability->get_name(); + } + + // Create the MCP server. + $adapter->create_server( + self::SERVER_ID, + self::SERVER_NAMESPACE, + self::SERVER_ROUTE, + __( 'Kit WordPress Plugin MCP', 'convertkit' ), + __( 'Exposes Kit Plugin abilities over the Model Context Protocol.', 'convertkit' ), + '1.0.0', + array( 'WP\\MCP\\Transport\\HttpTransport' ), + 'WP\\MCP\\Infrastructure\\ErrorHandling\\ErrorLogMcpErrorHandler', + 'WP\\MCP\\Infrastructure\\Observability\\NullMcpObservabilityHandler', + $ability_names, // Abilities (Tools). + array(), // Resources. + array() // Prompts. + ); + + } + +} diff --git a/tests/EndToEnd.suite.yml b/tests/EndToEnd.suite.yml index f00924664..23529dc84 100644 --- a/tests/EndToEnd.suite.yml +++ b/tests/EndToEnd.suite.yml @@ -41,6 +41,7 @@ modules: - \Tests\Support\Helper\WPGutenberg - \Tests\Support\Helper\WPMetabox - \Tests\Support\Helper\WPNotices + - \Tests\Support\Helper\WPRestAPI - \Tests\Support\Helper\WPQuickEdit - \Tests\Support\Helper\WPWidget - \Tests\Support\Helper\Xdebug diff --git a/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php b/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php new file mode 100644 index 000000000..cf0a22683 --- /dev/null +++ b/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php @@ -0,0 +1,175 @@ + Kit > MCP. + * + * @since 3.4.0 + */ +class PluginSettingsMCPCest +{ + /** + * Run common actions before running the test functions in this class. + * + * @since 3.4.0 + * + * @param EndToEndTester $I Tester. + */ + public function _before(EndToEndTester $I) + { + // Activate Kit Plugin. + $I->activateKitPlugin($I); + + // Setup Plugin. + $I->setupKitPlugin($I); + } + + /** + * Tests that enabling and disabling the MCP server setting works with no errors. + * + * @since 3.4.0 + * + * @param EndToEndTester $I Tester. + */ + public function testEnableAndDisableMCPServerSetting(EndToEndTester $I) + { + // Check that the MCP server is not registered. + $I->doesNotHaveRoute($I, '/kit-mcp'); + + // Go to the Plugin's MCP Screen. + $I->loadKitSettingsMCPScreen($I); + + // Enable MCP server. + $I->checkOption('#enabled'); + $I->click('Save Changes'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + + // Check that the MCP server is enabled. + $I->waitForElementVisible('#enabled'); + $I->seeCheckboxIsChecked('#enabled'); + + // Check that the MCP server is registered. + $I->hasRoute($I, '/kit/mcp'); + $I->hasRoute($I, '/kit/mcp/v1'); + + // Disable MCP server. + $I->uncheckOption('#enabled'); + $I->click('Save Changes'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + + // Check that the MCP server is disabled. + $I->waitForElementVisible('#enabled'); + $I->dontSeeCheckboxIsChecked('#enabled'); + + // Go to the Plugin's MCP Screen. + $I->loadKitSettingsMCPScreen($I); + $I->wait(2); + + // Check that the MCP server is not registered. + $I->doesNotHaveRoute($I, '/kit/mcp'); + $I->doesNotHaveRoute($I, '/kit/mcp/v1'); + } + + /** + * Tests that generating and revoking an Application Password works with no errors + * and that the MCP server is accessible using the Authorization Header generated + * via the Application Password. + * + * @since 3.4.0 + * + * @param EndToEndTester $I Tester. + */ + public function testGenerateAndRevokeApplicationPassword(EndToEndTester $I) + { + // Go to the Plugin's MCP Screen. + $I->loadKitSettingsMCPScreen($I); + + // Enable MCP server. + $I->checkOption('#enabled'); + $I->click('Save Changes'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + + // Check that the MCP server is enabled. + $I->waitForElementVisible('#enabled'); + $I->seeCheckboxIsChecked('#enabled'); + + // Click Create Application Password button. + $I->click('Create Application Password'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + + // Check that the application password was created and contains the correct name. + $I->waitForElementVisible('#app_name'); + $I->seeInField('#app_name', 'Kit WordPress Plugin: MCP Server'); + + // Approve the application password. + $I->click('input#approve'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + + // Check that the user is back on the Settings > Kit > MCP screen and the Authentication Header is displayed. + $I->waitForElementVisible('#kit-authorization-header'); + + // Perform a JSON-RPC `initialize` request against the MCP server using + // the Authorization Header generated via the Application Password. + $response = $I->callRestEndpoint( + '/kit/mcp/v1', + $I->grabTextFrom('#kit-authorization-header'), + 'POST', + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'capabilities' => new \stdClass(), + 'clientInfo' => [ + 'name' => 'kit-wordpress-plugin-test', + 'version' => '1.0', + ], + ], + ] + ); + + // Assert the request was authorised and the discovery endpoint responded. + $I->assertEquals(200, $response['status']); + $I->assertEquals('Kit WordPress Plugin MCP', $response['body']['result']['serverInfo']['name'] ?? null); + + // Reload the MCP settings screen and confirm the Authorization Header is not displayed. + $I->loadKitSettingsMCPScreen($I); + $I->waitForText('It is not displayed here for security.'); + $I->waitForElementNotVisible('#kit-authorization-header'); + + // Revoke the application password. + $I->click('#convertkit-settings-mcp-revoke-application-password'); + + // Check that the Revoke Application Password button is no longer visible. + $I->waitForElementNotVisible('#convertkit-settings-mcp-revoke-application-password'); + } + + /** + * Deactivate and reset Plugin(s) after each test, if the test passes. + * We don't use _after, as this would provide a screenshot of the Plugin + * deactivation and not the true test error. + * + * @since 3.4.0 + * + * @param EndToEndTester $I Tester. + */ + public function _passed(EndToEndTester $I) + { + $I->deactivateKitPlugin($I); + $I->resetKitPlugin($I); + } +} diff --git a/tests/Integration/BlockPostHelperTest.php b/tests/Integration/BlockPostHelperTest.php new file mode 100644 index 000000000..020c2bc0d --- /dev/null +++ b/tests/Integration/BlockPostHelperTest.php @@ -0,0 +1,473 @@ +postID = $this->createPost(); + } + + /** + * Performs actions after each test. + * + * @since 3.4.0 + */ + public function tearDown(): void + { + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * Test that the find() method returns the correct block indicies and attributes. + * + * @since 3.4.0 + */ + public function testFind() + { + // Find the block. + $blocks = \ConvertKit_Block_Post_Helper::find( $this->postID, 'convertkit/form' ); + $this->assertIsArray( $blocks ); + $this->assertCount( 2, $blocks ); + + // Assert first matching block indicies and attributes are correct. + $this->assertEquals( 0, $blocks[0]['occurrence_index'] ); + $this->assertEquals( $_ENV['CONVERTKIT_API_FORM_ID'], $blocks[0]['attrs']['form'] ); + + // Assert second matching block indicies and attributes are correct. + $this->assertEquals( 1, $blocks[1]['occurrence_index'] ); + $this->assertEquals( $_ENV['CONVERTKIT_API_FORM_ID'], $blocks[1]['attrs']['form'] ); + } + + /** + * Test that the find() method returns an empty array when no blocks match the given block name. + * + * @since 3.4.0 + */ + public function testFindWhenNoBlocksMatch() + { + $blocks = \ConvertKit_Block_Post_Helper::find( $this->postID, 'fake/block' ); + $this->assertIsArray($blocks); + $this->assertCount(0, $blocks); + } + + /** + * Test that the find() method returns a WP_Error when the post does not exist. + * + * @since 3.4.0 + */ + public function testFindWhenPostDoesNotExist() + { + $this->assertInstanceOf(\WP_Error::class, \ConvertKit_Block_Post_Helper::find( 999999, 'convertkit/form' )); + } + + /** + * Test that the insert() method inserts a new block at the beginning of the content + * when the position is set to prepend. + * + * @since 3.4.0 + */ + public function testInsertPrepend() + { + $result = \ConvertKit_Block_Post_Helper::insert( + post_id: $this->postID, + block_name: 'convertkit/form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'prepend' + ); + + // Confirm result is an array and the post ID is correct. + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the block is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringStartsWith( '', $post->post_content ); + } + + /** + * Test that the insert() method inserts a new block at the end of the content + * when the position is set to append. + * + * @since 3.4.0 + */ + public function testInsertAppend() + { + $result = \ConvertKit_Block_Post_Helper::insert( + post_id: $this->postID, + block_name: 'convertkit/form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'append' + ); + + // Confirm result is an array and the post ID is correct. + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the block is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringEndsWith( '', $post->post_content ); + } + + /** + * Test that the insert() method inserts a new block at the specified index position. + * + * @since 3.4.0 + */ + public function testInsertIndex() + { + $result = \ConvertKit_Block_Post_Helper::insert( + post_id: $this->postID, + block_name: 'convertkit/form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 1 + ); + + // Confirm result is an array and the post ID is correct. + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the block is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringContainsString( "\n

Item #1

\n', $post->post_content ); + } + + /** + * Test that the insert() method inserts a new block at the specified index position. + * + * @since 3.4.0 + */ + public function testInsertIndexZero() + { + $result = \ConvertKit_Block_Post_Helper::insert( + post_id: $this->postID, + block_name: 'convertkit/form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 0 + ); + + // Confirm result is an array and the post ID is correct. + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the block is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringStartsWith( '', $post->post_content ); + } + + /** + * Test that the insert() method inserts a new block at end of the content when + * the index is out of bounds. + * + * @since 3.4.0 + */ + public function testInsertIndexOutOfBounds() + { + $result = \ConvertKit_Block_Post_Helper::insert( + post_id: $this->postID, + block_name: 'convertkit/form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 100 + ); + + // Confirm result is an array and the post ID is correct. + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the block is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringEndsWith( '', $post->post_content ); + } + + /** + * Test that the insert() method returns a WP_Error when the index is negative. + * + * @since 3.4.0 + */ + public function testInsertIndexNegative() + { + $result = \ConvertKit_Block_Post_Helper::insert( + post_id: $this->postID, + block_name: 'convertkit/form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: -1 + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the insert() method returns a WP_Error when the post does not exist. + * + * @since 3.4.0 + */ + public function testInsertWhenPostDoesNotExist() + { + $result = \ConvertKit_Block_Post_Helper::insert( + post_id: 999999, + block_name: 'convertkit/form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 0 + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the update() method updates the attributes of an existing block. + * + * @since 3.4.0 + */ + public function testUpdate() + { + $result = \ConvertKit_Block_Post_Helper::update( + post_id: $this->postID, + block_name: 'convertkit/form', + occurrence_index: 0, + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + $result = \ConvertKit_Block_Post_Helper::update( + post_id: $this->postID, + block_name: 'convertkit/form', + occurrence_index: 1, + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + } + + /** + * Test that the update() method returns a WP_Error when the occurrence index is out of bounds. + * + * @since 3.4.0 + */ + public function testUpdateWhenOccurrenceIndexIsOutOfBounds() + { + $result = \ConvertKit_Block_Post_Helper::update( + post_id: $this->postID, + block_name: 'convertkit/form', + occurrence_index: 999, + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the update() method returns a WP_Error when the post does not exist. + * + * @since 3.4.0 + */ + public function testUpdateWhenPostDoesNotExist() + { + $result = \ConvertKit_Block_Post_Helper::update( + post_id: 999999, + block_name: 'convertkit/form', + occurrence_index: 0, + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the delete() method deletes an existing block. + * + * @since 3.4.0 + */ + public function testDelete() + { + $result = \ConvertKit_Block_Post_Helper::delete( + post_id: $this->postID, + block_name: 'convertkit/form', + occurrence_index: 1 + ); + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + $result = \ConvertKit_Block_Post_Helper::delete( + post_id: $this->postID, + block_name: 'convertkit/form', + occurrence_index: 0 + ); + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + } + + /** + * Test that the delete() method returns a WP_Error when the occurrence index is out of bounds. + * + * @since 3.4.0 + */ + public function testDeleteWhenOccurrenceIndexIsOutOfBounds() + { + $result = \ConvertKit_Block_Post_Helper::delete( + post_id: $this->postID, + block_name: 'convertkit/form', + occurrence_index: 999 + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the delete() method returns a WP_Error when the post does not exist. + * + * @since 3.4.0 + */ + public function testDeleteWhenPostDoesNotExist() + { + $result = \ConvertKit_Block_Post_Helper::delete( + post_id: 999999, + block_name: 'convertkit/form', + occurrence_index: 0 + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Mocks a post for testing. + * + * @since 3.4.0 + * @return int + */ + private function createPost() + { + // Create a Post with the given block. + return $this->factory->post->create( + [ + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Block Post', + 'post_content' => ' +

Item #1

+ + + +

Item #1

+ + + +

Item #2: Adhaésionés altéram improbis mi pariendarum sit stulti triarium

+ + + +
Image #1
+ + + +

Item #2

+ + + + + +

Item #3

+ + + +
Image #2
+ + + + + +

Item #1

+ + + +

Item #4

+ + + +

Item #1

+ + + +

Item #5

+ + + +

Item #2

+ + + +

Item #2

+', + ] + ); + } +} 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/tests/Integration/MCPContentBroadcastsTest.php b/tests/Integration/MCPContentBroadcastsTest.php new file mode 100644 index 000000000..1d2a6a1f7 --- /dev/null +++ b/tests/Integration/MCPContentBroadcastsTest.php @@ -0,0 +1,368 @@ +postID = $this->createPostWithBroadcastsBlocks(); + } + + /** + * Performs actions after each test. + * + * @since 3.4.0 + */ + public function tearDown(): void + { + // Restore the current user. + wp_set_current_user(0); + + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * The ability names registered by the Broadcasts block. + * + * @since 3.4.0 + * + * @var string[] + */ + private const BROADCASTS_ABILITY_NAMES = array( + 'kit/broadcasts-list', + 'kit/broadcasts-insert', + 'kit/broadcasts-update', + 'kit/broadcasts-delete', + ); + + /** + * Test that the Broadcasts block registers all four content abilities via + * the convertkit_abilities filter with the expected names. + * + * @since 3.4.0 + */ + public function testAbilitiesRegistered() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // The ability names and classes expected to be registered. + $expected = array( + 'kit/broadcasts-list' => \ConvertKit_MCP_Ability_Content_List::class, + 'kit/broadcasts-insert' => \ConvertKit_MCP_Ability_Content_Insert::class, + 'kit/broadcasts-update' => \ConvertKit_MCP_Ability_Content_Update::class, + 'kit/broadcasts-delete' => \ConvertKit_MCP_Ability_Content_Delete::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot edit the + * given post. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutEditPostCapability() + { + // Become a Subscriber (no edit_post capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::BROADCASTS_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() rejects a request with no post_id, + * with a clear error code. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutPostId() + { + // Become an Administrator (has every capability, so the only thing + // that can fail here is the missing post_id check). + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::BROADCASTS_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() permits an Administrator on a + * valid post_id. + * + * @since 3.4.0 + */ + public function testPermissionCallbackPermitsAdministrator() + { + // Become an Administrator. + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission granted. + foreach ( self::BROADCASTS_ABILITY_NAMES as $name ) { + // Execute the ability. + $this->assertTrue($abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ])); + } + } + + /** + * Test that kit/broadcasts-list returns every Broadcasts block occurrence + * in the post. + * + * @since 3.4.0 + */ + public function testListReturnsAllBroadcastsOccurrencesInPost() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/broadcasts-list']->execute_callback([ 'post_id' => $this->postID ]); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['count']); + $this->assertCount(2, $result['occurrences']); + + // Each occurrence carries an occurrence_index and an attrs object + // holding the limit attribute from the seeded post content. + foreach ($result['occurrences'] as $i => $occurrence) { + $this->assertSame($i, $occurrence['occurrence_index']); + $this->assertArrayHasKey('attrs', $occurrence); + $this->assertSame(5, (int) $occurrence['attrs']['limit']); + } + } + + /** + * Test that kit/broadcasts-insert appends a new Broadcasts block to the + * post, and returns the new occurrence_index. + * + * @since 3.4.0 + */ + public function testInsertAppendsBroadcastsBlock() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/broadcasts-insert']->execute_callback( + array( + 'post_id' => $this->postID, + 'attrs' => array( + 'limit' => 3, + 'display_image' => true, + ), + 'position' => 'append', + ) + ); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['occurrence_index']); + + // Confirm the post now contains three Broadcasts blocks. + $listed = $abilities['kit/broadcasts-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(3, $listed['count']); + + // Confirm the newly inserted block carries the attrs we passed in. + $this->assertSame(3, (int) $listed['occurrences'][2]['attrs']['limit']); + $this->assertTrue( (bool) $listed['occurrences'][2]['attrs']['display_image']); + } + + /** + * Test that kit/broadcasts-update changes the attrs of a specific + * occurrence, leaving other occurrences untouched. + * + * @since 3.4.0 + */ + public function testUpdateModifiesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Update the second Broadcasts block (occurrence_index 1) to a different limit. + $result = $abilities['kit/broadcasts-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 1, + 'attrs' => array( 'limit' => 25 ), + ) + ); + + $this->assertIsArray($result); + $this->assertSame(1, $result['occurrence_index']); + + // Re-list and confirm: occurrence 0 unchanged, occurrence 1 has the new limit. + $listed = $abilities['kit/broadcasts-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame( + 5, + (int) $listed['occurrences'][0]['attrs']['limit'], + 'kit/broadcasts-update must not modify other occurrences.' + ); + $this->assertSame( + 25, + (int) $listed['occurrences'][1]['attrs']['limit'], + 'kit/broadcasts-update did not apply the new limit to the requested occurrence.' + ); + } + + /** + * Test that kit/broadcasts-delete removes a specific occurrence and the + * post now contains one fewer Broadcasts block. + * + * @since 3.4.0 + */ + public function testDeleteRemovesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/broadcasts-delete']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 0, + ) + ); + + $this->assertIsArray($result); + $this->assertSame(0, $result['occurrence_index']); + + // Confirm the post now contains a single Broadcasts block. + $listed = $abilities['kit/broadcasts-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(1, $listed['count']); + } + + /** + * Test that kit/broadcasts-update returns a WP_Error when asked to update + * an occurrence that does not exist, rather than silently mutating + * something else. + * + * @since 3.4.0 + */ + public function testUpdateOnMissingOccurrenceReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/broadcasts-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 99, + 'attrs' => array( 'limit' => 5 ), + ) + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Creates a Post containing two convertkit/broadcasts blocks interleaved + * with non-Kit blocks, mirroring the fixture used by BlockPostHelperTest. + * + * @since 3.4.0 + * + * @return int + */ + private function createPostWithBroadcastsBlocks(): int + { + return $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Broadcasts Abilities Fixture', + 'post_content' => ' +

Intro paragraph.

+ + + + + +

Middle paragraph.

+ + + + + +

Closing paragraph.

+', + ) + ); + } +} diff --git a/tests/Integration/MCPContentFormTest.php b/tests/Integration/MCPContentFormTest.php new file mode 100644 index 000000000..1bad4ba47 --- /dev/null +++ b/tests/Integration/MCPContentFormTest.php @@ -0,0 +1,366 @@ +postID = $this->createPostWithFormBlocks(); + } + + /** + * Performs actions after each test. + * + * @since 3.4.0 + */ + public function tearDown(): void + { + // Restore the current user. + wp_set_current_user(0); + + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * The ability names registered by the Form block. + * + * @since 3.4.0 + * + * @var string[] + */ + private const FORM_ABILITY_NAMES = array( + 'kit/form-list', + 'kit/form-insert', + 'kit/form-update', + 'kit/form-delete', + ); + + /** + * Test that the Form block registers all four content abilities via the + * convertkit_abilities filter with the expected names. + * + * @since 3.4.0 + */ + public function testAbilitiesRegistered() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // The ability names and classes expected to be registered. + $expected = array( + 'kit/form-list' => \ConvertKit_MCP_Ability_Content_List::class, + 'kit/form-insert' => \ConvertKit_MCP_Ability_Content_Insert::class, + 'kit/form-update' => \ConvertKit_MCP_Ability_Content_Update::class, + 'kit/form-delete' => \ConvertKit_MCP_Ability_Content_Delete::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot edit the + * given post. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutEditPostCapability() + { + // Become a Subscriber (no edit_post capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::FORM_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() rejects a request with no post_id, + * with a clear error code. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutPostId() + { + // Become an Administrator (has every capability, so the only thing + // that can fail here is the missing post_id check). + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::FORM_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() permits an Administrator on a + * valid post_id. + * + * @since 3.4.0 + */ + public function testPermissionCallbackPermitsAdministrator() + { + // Become an Administrator. + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission granted. + foreach ( self::FORM_ABILITY_NAMES as $name ) { + // Execute the ability. + $this->assertTrue($abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ])); + } + } + + /** + * Test that kit/form-list returns every Form block occurrence in the + * post, with shape { post_id, count, occurrences: [{occurrence_index, attrs}] }. + * + * @since 3.4.0 + */ + public function testListReturnsAllFormOccurrencesInPost() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['count']); + $this->assertCount(2, $result['occurrences']); + + // Each occurrence carries an occurrence_index and an attrs object + // holding the form ID from the seeded post content. + foreach ($result['occurrences'] as $i => $occurrence) { + $this->assertSame($i, $occurrence['occurrence_index']); + $this->assertArrayHasKey('attrs', $occurrence); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_FORM_ID'], + (string) $occurrence['attrs']['form'] + ); + } + } + + /** + * Test that kit/form-insert appends a new Form block to the post, and + * returns the new occurrence_index. + * + * @since 3.4.0 + */ + public function testInsertAppendsFormBlock() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/form-insert']->execute_callback( + array( + 'post_id' => $this->postID, + 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ), + 'position' => 'append', + ) + ); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['occurrence_index']); + + // Confirm the post now contains three Form blocks. + $listed = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(3, $listed['count']); + } + + /** + * Test that kit/form-update changes the attrs of a specific occurrence, + * leaving other occurrences untouched. + * + * @since 3.4.0 + */ + public function testUpdateModifiesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Update the second Form block (occurrence_index 1) to a different form ID. + $new_form_id = (string) ( (int) $_ENV['CONVERTKIT_API_FORM_ID'] + 1 ); + $result = $abilities['kit/form-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 1, + 'attrs' => array( 'form' => $new_form_id ), + ) + ); + + $this->assertIsArray($result); + $this->assertSame(1, $result['occurrence_index']); + + // Re-list and confirm: occurrence 0 unchanged, occurrence 1 has the new form ID. + $listed = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_FORM_ID'], + (string) $listed['occurrences'][0]['attrs']['form'], + 'kit/form-update must not modify other occurrences.' + ); + $this->assertSame( + $new_form_id, + (string) $listed['occurrences'][1]['attrs']['form'], + 'kit/form-update did not apply the new form ID to the requested occurrence.' + ); + } + + /** + * Test that kit/form-delete removes a specific occurrence and the post + * now contains one fewer Form block. + * + * @since 3.4.0 + */ + public function testDeleteRemovesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/form-delete']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 0, + ) + ); + + $this->assertIsArray($result); + $this->assertSame(0, $result['occurrence_index']); + + // Confirm the post now contains a single Form block. + $listed = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(1, $listed['count']); + } + + /** + * Test that kit/form-update returns a WP_Error when asked to update an + * occurrence that does not exist, rather than silently mutating + * something else. + * + * @since 3.4.0 + */ + public function testUpdateOnMissingOccurrenceReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/form-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 99, + 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ), + ) + ); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Creates a Post containing two convertkit/form blocks interleaved with + * non-Kit blocks, mirroring the fixture used by BlockPostHelperTest. + * + * @since 3.4.0 + * + * @return int + */ + private function createPostWithFormBlocks(): int + { + return $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Form Abilities Fixture', + 'post_content' => ' +

Intro paragraph.

+ + + + + +

Middle paragraph.

+ + + + + +

Closing paragraph.

+', + ) + ); + } +} diff --git a/tests/Integration/MCPContentFormTriggerTest.php b/tests/Integration/MCPContentFormTriggerTest.php new file mode 100644 index 000000000..afceca369 --- /dev/null +++ b/tests/Integration/MCPContentFormTriggerTest.php @@ -0,0 +1,366 @@ +postID = $this->createPostWithFormTriggerBlocks(); + } + + /** + * Performs actions after each test. + * + * @since 3.4.0 + */ + public function tearDown(): void + { + // Restore the current user. + wp_set_current_user(0); + + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * The ability names registered by the Form Trigger block. + * + * @since 3.4.0 + * + * @var string[] + */ + private const FORM_TRIGGER_ABILITY_NAMES = array( + 'kit/formtrigger-list', + 'kit/formtrigger-insert', + 'kit/formtrigger-update', + 'kit/formtrigger-delete', + ); + + /** + * Test that the Form Trigger block registers all four content abilities via the + * convertkit_abilities filter with the expected names. + * + * @since 3.4.0 + */ + public function testAbilitiesRegistered() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // The ability names and classes expected to be registered. + $expected = array( + 'kit/formtrigger-list' => \ConvertKit_MCP_Ability_Content_List::class, + 'kit/formtrigger-insert' => \ConvertKit_MCP_Ability_Content_Insert::class, + 'kit/formtrigger-update' => \ConvertKit_MCP_Ability_Content_Update::class, + 'kit/formtrigger-delete' => \ConvertKit_MCP_Ability_Content_Delete::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot edit the + * given post. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutEditPostCapability() + { + // Become a Subscriber (no edit_post capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::FORM_TRIGGER_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() rejects a request with no post_id, + * with a clear error code. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutPostId() + { + // Become an Administrator (has every capability, so the only thing + // that can fail here is the missing post_id check). + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::FORM_TRIGGER_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() permits an Administrator on a + * valid post_id. + * + * @since 3.4.0 + */ + public function testPermissionCallbackPermitsAdministrator() + { + // Become an Administrator. + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission granted. + foreach ( self::FORM_TRIGGER_ABILITY_NAMES as $name ) { + // Execute the ability. + $this->assertTrue($abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ])); + } + } + + /** + * Test that kit/formtrigger-list returns every Form Trigger block occurrence in the + * post, with shape { post_id, count, occurrences: [{occurrence_index, attrs}] }. + * + * @since 3.4.0 + */ + public function testListReturnsAllFormTriggerOccurrencesInPost() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/formtrigger-list']->execute_callback([ 'post_id' => $this->postID ]); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['count']); + $this->assertCount(2, $result['occurrences']); + + // Each occurrence carries an occurrence_index and an attrs object + // holding the form ID from the seeded post content. + foreach ($result['occurrences'] as $i => $occurrence) { + $this->assertSame($i, $occurrence['occurrence_index']); + $this->assertArrayHasKey('attrs', $occurrence); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'], + (string) $occurrence['attrs']['form'] + ); + } + } + + /** + * Test that kit/formtrigger-insert appends a new Form Trigger block to the post, and + * returns the new occurrence_index. + * + * @since 3.4.0 + */ + public function testInsertAppendsFormTriggerBlock() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/formtrigger-insert']->execute_callback( + array( + 'post_id' => $this->postID, + 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'] ), + 'position' => 'append', + ) + ); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['occurrence_index']); + + // Confirm the post now contains three Form Trigger blocks. + $listed = $abilities['kit/formtrigger-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(3, $listed['count']); + } + + /** + * Test that kit/formtrigger-update changes the attrs of a specific occurrence, + * leaving other occurrences untouched. + * + * @since 3.4.0 + */ + public function testUpdateModifiesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Update the second Form Trigger block (occurrence_index 1) to a different form ID. + $new_form_id = (string) ( (int) $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'] + 1 ); + $result = $abilities['kit/formtrigger-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 1, + 'attrs' => array( 'form' => $new_form_id ), + ) + ); + + $this->assertIsArray($result); + $this->assertSame(1, $result['occurrence_index']); + + // Re-list and confirm: occurrence 0 unchanged, occurrence 1 has the new form ID. + $listed = $abilities['kit/formtrigger-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'], + (string) $listed['occurrences'][0]['attrs']['form'], + 'kit/formtrigger-update must not modify other occurrences.' + ); + $this->assertSame( + $new_form_id, + (string) $listed['occurrences'][1]['attrs']['form'], + 'kit/formtrigger-update did not apply the new form ID to the requested occurrence.' + ); + } + + /** + * Test that kit/formtrigger-delete removes a specific occurrence and the post + * now contains one fewer Form Trigger block. + * + * @since 3.4.0 + */ + public function testDeleteRemovesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/formtrigger-delete']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 0, + ) + ); + + $this->assertIsArray($result); + $this->assertSame(0, $result['occurrence_index']); + + // Confirm the post now contains a single Form Trigger block. + $listed = $abilities['kit/formtrigger-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(1, $listed['count']); + } + + /** + * Test that kit/formtrigger-update returns a WP_Error when asked to update an + * occurrence that does not exist, rather than silently mutating + * something else. + * + * @since 3.4.0 + */ + public function testUpdateOnMissingOccurrenceReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/formtrigger-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 99, + 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'] ), + ) + ); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Creates a Post containing two convertkit/formtrigger blocks interleaved with + * non-Kit blocks, mirroring the fixture used by BlockPostHelperTest. + * + * @since 3.4.0 + * + * @return int + */ + private function createPostWithFormTriggerBlocks(): int + { + return $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Form Trigger Abilities Fixture', + 'post_content' => ' +

Intro paragraph.

+ + + + + +

Middle paragraph.

+ + + + + +

Closing paragraph.

+', + ) + ); + } +} diff --git a/tests/Integration/MCPContentProductTest.php b/tests/Integration/MCPContentProductTest.php new file mode 100644 index 000000000..50a6504ba --- /dev/null +++ b/tests/Integration/MCPContentProductTest.php @@ -0,0 +1,394 @@ +postID = $this->createPostWithProductBlocks(); + } + + /** + * Performs actions after each test. + * + * @since 3.4.0 + */ + public function tearDown(): void + { + // Restore the current user. + wp_set_current_user(0); + + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * The ability names registered by the Product block. + * + * @since 3.4.0 + * + * @var string[] + */ + private const PRODUCT_ABILITY_NAMES = array( + 'kit/product-list', + 'kit/product-insert', + 'kit/product-update', + 'kit/product-delete', + ); + + /** + * Test that the Product block registers all four content abilities via + * the convertkit_abilities filter with the expected names. + * + * @since 3.4.0 + */ + public function testAbilitiesRegistered() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // The ability names and classes expected to be registered. + $expected = array( + 'kit/product-list' => \ConvertKit_MCP_Ability_Content_List::class, + 'kit/product-insert' => \ConvertKit_MCP_Ability_Content_Insert::class, + 'kit/product-update' => \ConvertKit_MCP_Ability_Content_Update::class, + 'kit/product-delete' => \ConvertKit_MCP_Ability_Content_Delete::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot edit the + * given post. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutEditPostCapability() + { + // Become a Subscriber (no edit_post capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::PRODUCT_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() rejects a request with no post_id, + * with a clear error code. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutPostId() + { + // Become an Administrator (has every capability, so the only thing + // that can fail here is the missing post_id check). + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::PRODUCT_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() permits an Administrator on a + * valid post_id. + * + * @since 3.4.0 + */ + public function testPermissionCallbackPermitsAdministrator() + { + // Become an Administrator. + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission granted. + foreach ( self::PRODUCT_ABILITY_NAMES as $name ) { + // Execute the ability. + $this->assertTrue($abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ])); + } + } + + /** + * Test that kit/product-list returns every Product block occurrence in + * the post. + * + * @since 3.4.0 + */ + public function testListReturnsAllProductOccurrencesInPost() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/product-list']->execute_callback([ 'post_id' => $this->postID ]); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['count']); + $this->assertCount(2, $result['occurrences']); + + // Each occurrence carries an occurrence_index and an attrs object + // holding the product ID from the seeded post content. + foreach ($result['occurrences'] as $i => $occurrence) { + $this->assertSame($i, $occurrence['occurrence_index']); + $this->assertArrayHasKey('attrs', $occurrence); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_PRODUCT_ID'], + (string) $occurrence['attrs']['product'] + ); + } + } + + /** + * Test that kit/product-insert appends a new Product block to the post, + * and returns the new occurrence_index. Exercises all four primary + * Product attributes so each round-trips through the block helper. + * + * @since 3.4.0 + */ + public function testInsertAppendsProductBlock() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/product-insert']->execute_callback( + array( + 'post_id' => $this->postID, + 'attrs' => array( + 'product' => $_ENV['CONVERTKIT_API_PRODUCT_ID'], + 'text' => 'Buy this product', + 'discount_code' => $_ENV['CONVERTKIT_API_PRODUCT_DISCOUNT_CODE'], + 'checkout' => true, + ), + 'position' => 'append', + ) + ); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + // Two Product blocks existed in setUp(); the newly inserted one is the third. + $this->assertSame(2, $result['occurrence_index']); + + // Confirm the post now contains three Product blocks. + $listed = $abilities['kit/product-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(3, $listed['count']); + + // Confirm the newly inserted block carries the attrs we passed in. + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_PRODUCT_ID'], + (string) $listed['occurrences'][2]['attrs']['product'] + ); + $this->assertSame('Buy this product', $listed['occurrences'][2]['attrs']['text']); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_PRODUCT_DISCOUNT_CODE'], + (string) $listed['occurrences'][2]['attrs']['discount_code'] + ); + $this->assertTrue( (bool) $listed['occurrences'][2]['attrs']['checkout']); + } + + /** + * Test that kit/product-update changes the attrs of a specific + * occurrence, leaving other occurrences untouched. + * + * @since 3.4.0 + */ + public function testUpdateModifiesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Update the second Product block (occurrence_index 1) to a different + // product ID and text. + $new_product_id = (string) ( (int) $_ENV['CONVERTKIT_API_PRODUCT_ID'] + 1 ); + $result = $abilities['kit/product-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 1, + 'attrs' => array( + 'product' => $new_product_id, + 'text' => 'Updated CTA', + ), + ) + ); + + $this->assertIsArray($result); + $this->assertSame(1, $result['occurrence_index']); + + // Re-list and confirm: occurrence 0 unchanged, occurrence 1 has the + // new product ID and text. + $listed = $abilities['kit/product-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_PRODUCT_ID'], + (string) $listed['occurrences'][0]['attrs']['product'], + 'kit/product-update must not modify other occurrences.' + ); + $this->assertSame( + $new_product_id, + (string) $listed['occurrences'][1]['attrs']['product'], + 'kit/product-update did not apply the new product ID to the requested occurrence.' + ); + $this->assertSame( + 'Updated CTA', + $listed['occurrences'][1]['attrs']['text'], + 'kit/product-update did not apply the new text to the requested occurrence.' + ); + } + + /** + * Test that kit/product-delete removes a specific occurrence and the + * post now contains one fewer Product block. + * + * @since 3.4.0 + */ + public function testDeleteRemovesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/product-delete']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 0, + ) + ); + + $this->assertIsArray($result); + $this->assertSame(0, $result['occurrence_index']); + + // Confirm the post now contains a single Product block. + $listed = $abilities['kit/product-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(1, $listed['count']); + } + + /** + * Test that kit/product-update returns a WP_Error when asked to update + * an occurrence that does not exist, rather than silently mutating + * something else. + * + * @since 3.4.0 + */ + public function testUpdateOnMissingOccurrenceReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/product-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 99, + 'attrs' => array( 'product' => $_ENV['CONVERTKIT_API_PRODUCT_ID'] ), + ) + ); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Creates a Post containing two convertkit/product blocks interleaved + * with non-Kit blocks, mirroring the fixture used by BlockPostHelperTest. + * + * @since 3.4.0 + * + * @return int + */ + private function createPostWithProductBlocks(): int + { + return $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Product Abilities Fixture', + 'post_content' => ' +

Intro paragraph.

+ + + + + +

Middle paragraph.

+ + + + + +

Closing paragraph.

+', + ) + ); + } +} diff --git a/tests/Integration/MCPPostSettingsGetTest.php b/tests/Integration/MCPPostSettingsGetTest.php new file mode 100644 index 000000000..dbb9117a6 --- /dev/null +++ b/tests/Integration/MCPPostSettingsGetTest.php @@ -0,0 +1,218 @@ +assertArrayHasKey(self::ABILITY_NAME, $abilities); + $this->assertInstanceOf(\ConvertKit_MCP_Ability_Post_Settings_Get::class, $abilities[ self::ABILITY_NAME ]); + } + + /** + * Test that permission_callback() rejects an input with no post_id. + * + * @since 3.4.0 + */ + public function testPermissionCallbackRejectsMissingPostId() + { + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->permission_callback([]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_missing_post_id', $result->get_error_code()); + } + + /** + * Test that permission_callback() rejects a user who cannot edit the given post. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutEditPostCapability() + { + // Create a Post by an admin. + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + $post_id = static::factory()->post->create([ 'post_author' => $admin_id ]); + + // 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([ 'post_id' => $post_id ]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_cannot_edit_post', $result->get_error_code()); + } + + /** + * Test that get returns the default settings when the Post has no + * Kit post meta stored. + * + * @since 3.4.0 + */ + public function testGetReturnsDefaultsWhenNoMetaExists() + { + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + $post_id = static::factory()->post->create(); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'post_id' => $post_id ]); + + $this->assertIsArray($result); + $this->assertSame($post_id, $result['post_id']); + $this->assertSame('-1', $result['form']); + $this->assertSame('0', $result['landing_page']); + $this->assertSame('0', $result['tag']); + $this->assertSame('0', $result['restrict_content']); + } + + /** + * Test that get returns the stored Kit settings for a Post that has + * post meta saved. + * + * @since 3.4.0 + */ + public function testGetReturnsStoredSettings() + { + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + $post_id = static::factory()->post->create(); + + update_post_meta( + $post_id, + '_wp_convertkit_post_meta', + [ + 'form' => '123', + 'landing_page' => '456', + 'tag' => '789', + 'restrict_content' => 'product_101', + ] + ); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'post_id' => $post_id ]); + + $this->assertSame($post_id, $result['post_id']); + $this->assertSame('123', $result['form']); + $this->assertSame('456', $result['landing_page']); + $this->assertSame('789', $result['tag']); + $this->assertSame('product_101', $result['restrict_content']); + } + + /** + * Test that get returns settings for a Page (not just Posts) — confirms + * the ability isn't coupled to any single post type. + * + * @since 3.4.0 + */ + public function testGetWorksForPages() + { + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + $page_id = static::factory()->post->create([ 'post_type' => 'page' ]); + + update_post_meta( + $page_id, + '_wp_convertkit_post_meta', + [ + 'form' => '0', + 'landing_page' => '999', + 'tag' => '', + 'restrict_content' => '', + ] + ); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'post_id' => $page_id ]); + + $this->assertSame('0', $result['form']); + $this->assertSame('999', $result['landing_page']); + } + + /** + * Test that get returns a WP_Error when the given post_id does not exist. + * + * @since 3.4.0 + */ + public function testGetReturnsErrorForNonExistentPost() + { + $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([ 'post_id' => 999999 ]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_post_not_found', $result->get_error_code()); + } +} diff --git a/tests/Integration/MCPPostSettingsUpdateTest.php b/tests/Integration/MCPPostSettingsUpdateTest.php new file mode 100644 index 000000000..bcc6ce492 --- /dev/null +++ b/tests/Integration/MCPPostSettingsUpdateTest.php @@ -0,0 +1,393 @@ +assertArrayHasKey(self::ABILITY_NAME, $abilities); + $this->assertInstanceOf(\ConvertKit_MCP_Ability_Post_Settings_Update::class, $abilities[ self::ABILITY_NAME ]); + } + + /** + * Test that update writes all four settings and returns the post-save state. + * + * @since 3.4.0 + */ + public function testUpdateWritesAllFourSettings() + { + $post_id = $this->createPostAsAdmin(); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'post_id' => $post_id, + 'form' => '123', + 'landing_page' => '456', + 'tag' => '789', + 'restrict_content' => 'product_101', + ] + ); + + $this->assertIsArray($result); + $this->assertSame($post_id, $result['post_id']); + $this->assertSame('123', $result['form']); + $this->assertSame('456', $result['landing_page']); + $this->assertSame('789', $result['tag']); + $this->assertSame('product_101', $result['restrict_content']); + + // Confirm persisted to the DB. + $stored = get_post_meta($post_id, '_wp_convertkit_post_meta', true); + $this->assertSame('123', $stored['form']); + $this->assertSame('456', $stored['landing_page']); + $this->assertSame('789', $stored['tag']); + $this->assertSame('product_101', $stored['restrict_content']); + } + + /** + * Test that a partial update writes only the provided keys and preserves + * the other stored settings. + * + * @since 3.4.0 + */ + public function testUpdatePartialUpdatePreservesOtherKeys() + { + $post_id = $this->createPostAsAdmin(); + + // Seed existing settings. + update_post_meta( + $post_id, + '_wp_convertkit_post_meta', + [ + 'form' => '111', + 'landing_page' => '222', + 'tag' => '333', + 'restrict_content' => 'form_444', + ] + ); + + $abilities = convertkit_get_abilities(); + + // Update only the form. + $result = $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'post_id' => $post_id, + 'form' => '999', + ] + ); + + $this->assertSame('999', $result['form']); + $this->assertSame('222', $result['landing_page']); + $this->assertSame('333', $result['tag']); + $this->assertSame('form_444', $result['restrict_content']); + } + + /** + * Test that update rejects unknown keys in the input. + * + * @since 3.4.0 + */ + public function testUpdateRejectsUnknownKeys() + { + $post_id = $this->createPostAsAdmin(); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'post_id' => $post_id, + 'form' => '123', + 'not_a_field' => 'garbage', + ] + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_post_settings_unknown_keys', $result->get_error_code()); + } + + /** + * Test that update rejects a malformed form value (e.g. `abc`). + * + * @since 3.4.0 + */ + public function testUpdateRejectsInvalidFormValue() + { + $post_id = $this->createPostAsAdmin(); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'post_id' => $post_id, + 'form' => 'abc', + ] + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Test that update accepts `0` for restrict_content as a synonym for + * "no restriction". Matches what ConvertKit_Post::get_default_settings() + * returns and what the metabox / Gutenberg sidebar submit for the "None" + * option, so the get -> update round-trip works for defaulted posts. + * + * @since 3.4.0 + */ + public function testUpdateAcceptsZeroForRestrictContent() + { + $post_id = $this->createPostAsAdmin(); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'post_id' => $post_id, + 'restrict_content' => '0', + ] + ); + + $this->assertIsArray($result); + $this->assertSame('0', $result['restrict_content']); + } + + /** + * Test that update rejects a malformed restrict_content prefix + * (must be form_, tag_ or product_). + * + * @since 3.4.0 + */ + public function testUpdateRejectsInvalidRestrictContentFormat() + { + $post_id = $this->createPostAsAdmin(); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'post_id' => $post_id, + 'restrict_content' => 'sequence_123', + ] + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Test that update rejects a call with only post_id and no settings. + * + * @since 3.4.0 + */ + public function testUpdateRejectsWhenNoSettingsProvided() + { + $post_id = $this->createPostAsAdmin(); + + $abilities = convertkit_get_abilities(); + + $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'post_id' => $post_id ]); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_post_settings_no_input', $result->get_error_code()); + } + + /** + * Test that update returns a WP_Error when the given post_id does not exist. + * + * @since 3.4.0 + */ + public function testUpdateReturnsErrorForNonExistentPost() + { + $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( + [ + 'post_id' => 999999, + 'form' => '123', + ] + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('convertkit_mcp_post_not_found', $result->get_error_code()); + } + + /** + * Test that update -> get round-trip returns the updated values. + * + * @since 3.4.0 + */ + public function testUpdateThenGetRoundTrip() + { + $post_id = $this->createPostAsAdmin(); + + $abilities = convertkit_get_abilities(); + + $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'post_id' => $post_id, + 'form' => '555', + 'tag' => '666', + ] + ); + + $get_result = $abilities['kit/post-settings-get']->execute_callback([ 'post_id' => $post_id ]); + + $this->assertSame('555', $get_result['form']); + $this->assertSame('666', $get_result['tag']); + } + + /** + * Test that setting restrict_content on a published Post populates + * the Restrict Content cache option. Proves integration with the + * ConvertKit_Restrict_Content_Cache class. + * + * @since 3.4.0 + */ + public function testUpdateRestrictContentPopulatesCache() + { + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + $post_id = static::factory()->post->create( + [ + 'post_type' => 'page', + 'post_status' => 'publish', + ] + ); + + $abilities = convertkit_get_abilities(); + + $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'post_id' => $post_id, + 'restrict_content' => 'product_101', + ] + ); + + $cache = get_option(\ConvertKit_Restrict_Content_Cache::OPTION_NAME); + + $this->assertIsArray($cache); + $this->assertArrayHasKey($post_id, $cache); + } + + /** + * Test that clearing restrict_content removes the Post from the + * Restrict Content cache option. + * + * @since 3.4.0 + */ + public function testUpdateClearingRestrictContentRemovesFromCache() + { + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + $post_id = static::factory()->post->create( + [ + 'post_type' => 'page', + 'post_status' => 'publish', + ] + ); + + $abilities = convertkit_get_abilities(); + + // Enable. + $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'post_id' => $post_id, + 'restrict_content' => 'tag_123', + ] + ); + $this->assertArrayHasKey($post_id, get_option(\ConvertKit_Restrict_Content_Cache::OPTION_NAME)); + + // Clear. + $abilities[ self::ABILITY_NAME ]->execute_callback( + [ + 'post_id' => $post_id, + 'restrict_content' => '', + ] + ); + $this->assertArrayNotHasKey($post_id, get_option(\ConvertKit_Restrict_Content_Cache::OPTION_NAME)); + } + + /** + * Helper: creates an administrator user, switches to them, and returns + * a new Post ID. + * + * @since 3.4.0 + * + * @return int + */ + private function createPostAsAdmin() + { + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + return static::factory()->post->create(); + } +} diff --git a/tests/Integration/MCPResourceTest.php b/tests/Integration/MCPResourceTest.php new file mode 100644 index 000000000..e90cf8fd0 --- /dev/null +++ b/tests/Integration/MCPResourceTest.php @@ -0,0 +1,307 @@ +settings = new \ConvertKit_Settings(); + update_option( + $this->settings::SETTINGS_NAME, + [ + 'access_token' => $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], + 'refresh_token' => $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'], + ] + ); + } + + /** + * Performs actions after each test. + * + * @since 3.4.0 + */ + public function tearDown(): void + { + // Delete credentials and any cached resources so each test starts clean. + delete_option($this->settings::SETTINGS_NAME); + + foreach ( self::RESOURCE_CLASSES as $resource_class ) { + $resource = new $resource_class(); + delete_option($resource->settings_name); + delete_option($resource->settings_name . '_last_queried'); + } + + // Restore the current user. + wp_set_current_user(0); + + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * Map of resource-list ability names to the ConvertKit_Resource_* class + * backing them. Used by tests that need to seed / clear the resource + * cache alongside the ability under test. + * + * @since 3.4.0 + * + * @var array + */ + private const RESOURCE_CLASSES = array( + 'kit/forms-list' => \ConvertKit_Resource_Forms::class, + 'kit/tags-list' => \ConvertKit_Resource_Tags::class, + 'kit/landing-pages-list' => \ConvertKit_Resource_Landing_Pages::class, + 'kit/products-list' => \ConvertKit_Resource_Products::class, + ); + + /** + * Test that the four resource-list abilities are registered with the + * `convertkit_abilities` filter, so they are picked up by the Abilities + * API and exposed by the MCP server. + * + * @since 3.4.0 + */ + public function testAbilitiesRegistered() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // The ability names and classes expected to be registered. + $expected = array( + 'kit/forms-list' => \ConvertKit_MCP_Ability_Resource_Forms::class, + 'kit/tags-list' => \ConvertKit_MCP_Ability_Resource_Tags::class, + 'kit/landing-pages-list' => \ConvertKit_MCP_Ability_Resource_Landing_Pages::class, + 'kit/products-list' => \ConvertKit_MCP_Ability_Resource_Products::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user without the + * edit_posts capability. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutEditPostsCapability() + { + // Become a Subscriber (no edit_posts capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( array_keys( self::RESOURCE_CLASSES ) as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() permits a user with the edit_posts + * capability (e.g. an Editor or Administrator). + * + * @since 3.4.0 + */ + public function testPermissionCallbackPermitsWithEditPostsCapability() + { + // Become an Editor (has edit_posts capability). + $editor_id = static::factory()->user->create([ 'role' => 'editor' ]); + wp_set_current_user($editor_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission granted. + foreach ( array_keys( self::RESOURCE_CLASSES ) as $name ) { + // Execute the ability. + $this->assertTrue($abilities[ $name ]->permission_callback([])); + } + } + + /** + * Test that the execute_callback() returns an empty (but successful) list + * when the resource cache is empty, rather than an error. + * + * @since 3.4.0 + */ + public function testReturnsEmptyListWhenNoResourcesAreCached() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + foreach ( self::RESOURCE_CLASSES as $name => $resource_class ) { + // Ensure the cache is empty for this resource. + delete_option( ( new $resource_class() )->settings_name ); + + // Execute the ability. + $result = $abilities[ $name ]->execute_callback([]); + + $this->assertIsArray($result); + $this->assertArrayHasKey('count', $result); + $this->assertArrayHasKey('items', $result); + $this->assertSame(0, $result['count']); + $this->assertSame([], $result['items']); + } + } + + /** + * Test that execute_callback() returns the cached items, shaped as + * { count, items: [{ id, name, ... }] }, when the resource cache is + * populated. + * + * @since 3.4.0 + */ + public function testReturnsCachedItems() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the abilities. + foreach ( self::RESOURCE_CLASSES as $name => $resource_class ) { + // Populate the resource cache from the Kit API. + ( new $resource_class() )->init(); + + // Execute the ability. + $result = $abilities[ $name ]->execute_callback([]); + + $this->assertIsArray($result); + $this->assertArrayHasKey('count', $result); + $this->assertArrayHasKey('items', $result); + $this->assertGreaterThan(0, $result['count']); + $this->assertCount($result['count'], $result['items']); + + // Each item must have id and name. + foreach ($result['items'] as $item) { + $this->assertArrayHasKey('id', $item); + $this->assertArrayHasKey('name', $item); + $this->assertIsInt($item['id']); + $this->assertIsString($item['name']); + } + } + } + + /** + * Test that the Forms ability includes the `format` field on each item, + * and that legacy forms (which omit `format` in the raw resource cache) + * fall back to 'inline'. + * + * @since 3.4.0 + */ + public function testFormsItemsIncludeFormat() + { + // Populate the resource cache from the Kit API. + ( new \ConvertKit_Resource_Forms() )->init(); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/forms-list']->execute_callback([]); + + // Assert that the result is an array. + $this->assertGreaterThan(0, $result['count']); + + // Assert that the result has items. + $allowedFormats = [ 'inline', 'modal', 'slide in', 'sticky bar' ]; + foreach ($result['items'] as $item) { + $this->assertArrayHasKey('format', $item); + $this->assertContains($item['format'], $allowedFormats); + } + } + + /** + * Test that the output schema returned by each ability advertises the + * same keys (id, name, plus format for forms) that execute_callback() + * actually returns. Guards against drift between map_item() and + * get_item_schema(). + * + * @since 3.4.0 + */ + public function testOutputSchemaMatchesExecuteShape() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the abilities. + foreach ( self::RESOURCE_CLASSES as $name => $resource_class ) { + // Populate the resource cache from the Kit API. + ( new $resource_class() )->init(); + + // Execute the ability. + $result = $abilities[ $name ]->execute_callback([]); + + if ($result['count'] === 0) { + // No items to compare against; skip this ability. + continue; + } + + // Assert that the output schema is an object. + $schema = $abilities[ $name ]->get_output_schema(); + $this->assertSame('object', $schema['type']); + $this->assertSame([ 'count', 'items' ], $schema['required']); + + // Assert that the item schema keys match the result item keys. + $itemSchemaKeys = array_keys($schema['properties']['items']['items']['properties']); + $itemKeys = array_keys($result['items'][0]); + + sort($itemSchemaKeys); + sort($itemKeys); + + $this->assertSame($itemSchemaKeys, $itemKeys); + } + } +} diff --git a/tests/Integration/MCPSettingsBroadcastsTest.php b/tests/Integration/MCPSettingsBroadcastsTest.php new file mode 100644 index 000000000..d00089f59 --- /dev/null +++ b/tests/Integration/MCPSettingsBroadcastsTest.php @@ -0,0 +1,233 @@ + \ConvertKit_MCP_Ability_Settings_Get::class, + 'kit/settings-broadcasts-update' => \ConvertKit_MCP_Ability_Settings_Update::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot manage options. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutManageOptionsCapability() + { + // Become a Subscriber (no manage_options capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that kit/settings-broadcasts-get returns the current settings. + * + * @since 3.4.0 + */ + public function testGetSettings() + { + // Populate settings. + $this->populateSettings(); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-broadcasts-get']->execute_callback([]); + + // Confirm expected settings are returned. + $this->assertArrayHasKey('enabled', $result); + $this->assertEquals('on', $result['enabled']); + $this->assertArrayHasKey('author_id', $result); + $this->assertArrayHasKey('post_status', $result); + $this->assertEquals('draft', $result['post_status']); + $this->assertArrayHasKey('category_id', $result); + $this->assertArrayHasKey('import_thumbnail', $result); + $this->assertArrayHasKey('import_images', $result); + $this->assertArrayHasKey('published_at_min_date', $result); + $this->assertArrayHasKey('enabled_export', $result); + $this->assertArrayHasKey('no_styles', $result); + } + + /** + * Test that kit/settings-broadcasts-update updates the settings. + * + * @since 3.4.0 + */ + public function testUpdateSettings() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-broadcasts-update']->execute_callback( + [ + 'enabled' => 'on', + 'post_status' => 'draft', + 'import_thumbnail' => '', + 'import_images' => 'on', + 'enabled_export' => 'on', + 'no_styles' => 'on', + ] + ); + + // Confirm expected settings are returned. + $this->assertArrayHasKey('enabled', $result); + $this->assertArrayHasKey('author_id', $result); + $this->assertArrayHasKey('post_status', $result); + $this->assertArrayHasKey('category_id', $result); + $this->assertArrayHasKey('import_thumbnail', $result); + $this->assertArrayHasKey('import_images', $result); + $this->assertArrayHasKey('published_at_min_date', $result); + $this->assertArrayHasKey('enabled_export', $result); + $this->assertArrayHasKey('no_styles', $result); + + // Confirm settings are updated. + $this->assertEquals('on', $result['enabled']); + $this->assertEquals('draft', $result['post_status']); + $this->assertEquals('', $result['import_thumbnail']); + $this->assertEquals('on', $result['import_images']); + $this->assertEquals('on', $result['enabled_export']); + $this->assertEquals('on', $result['no_styles']); + } + + /** + * Test that kit/settings-broadcasts-update returns an error if an invalid key is provided. + * + * @since 3.4.0 + */ + public function testUpdateSettingsWithInvalidKeyReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-broadcasts-update']->execute_callback([ 'invalid_key' => 'invalid_value' ]); + } + + /** + * Populate the settings with some sensible values for testing. + * + * @since 3.4.0 + */ + private function populateSettings() + { + update_option( + self::SETTINGS_NAME, + [ + 'enabled' => 'on', + 'author_id' => 1, + 'post_status' => 'draft', + 'category_id' => '', + 'import_thumbnail' => 'on', + 'import_images' => '', + 'published_at_min_date' => gmdate( 'Y-m-d', strtotime( '-30 days' ) ), + 'enabled_export' => '', + 'no_styles' => '', + ] + ); + } +} diff --git a/tests/Integration/MCPSettingsGeneralTest.php b/tests/Integration/MCPSettingsGeneralTest.php new file mode 100644 index 000000000..b9d75ef56 --- /dev/null +++ b/tests/Integration/MCPSettingsGeneralTest.php @@ -0,0 +1,613 @@ + \ConvertKit_MCP_Ability_Settings_Get::class, + 'kit/settings-general-update' => \ConvertKit_MCP_Ability_Settings_Update::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot manage options. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutManageOptionsCapability() + { + // Become a Subscriber (no manage_options capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that kit/settings-general-get returns the current settings. + * + * @since 3.4.0 + */ + public function testGetSettings() + { + // Populate settings. + $this->populateSettings(); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-general-get']->execute_callback([]); + + // Confirm secret keys are not returned. + $this->assertArrayNotHasKey('access_token', $result); + $this->assertArrayNotHasKey('refresh_token', $result); + $this->assertArrayNotHasKey('token_expires', $result); + $this->assertArrayNotHasKey('api_key', $result); + $this->assertArrayNotHasKey('api_secret', $result); + $this->assertArrayNotHasKey('recaptcha_secret_key', $result); + + // Confirm expected settings are returned. + $this->assertArrayHasKey('non_inline_form', $result); + $this->assertArrayHasKey('non_inline_form_honor_none_setting', $result); + $this->assertArrayHasKey('non_inline_form_limit_per_session', $result); + $this->assertArrayHasKey('recaptcha_site_key', $result); + $this->assertArrayHasKey('recaptcha_minimum_score', $result); + $this->assertArrayHasKey('debug', $result); + $this->assertEquals('on', $result['debug']); + $this->assertArrayHasKey('no_scripts', $result); + $this->assertArrayHasKey('no_css', $result); + $this->assertArrayHasKey('no_add_new_button', $result); + $this->assertArrayHasKey('usage_tracking', $result); + + // Confirm per-post-type Default Form settings are returned for + // each supported post type (page, post at minimum). + $this->assertArrayHasKey('page_form', $result); + $this->assertArrayHasKey('page_form_position', $result); + $this->assertArrayHasKey('page_form_position_element', $result); + $this->assertArrayHasKey('page_form_position_element_index', $result); + $this->assertArrayHasKey('post_form', $result); + $this->assertArrayHasKey('post_form_position', $result); + $this->assertArrayHasKey('post_form_position_element', $result); + $this->assertArrayHasKey('post_form_position_element_index', $result); + + // Confirm per-post-type Default Form values round-trip correctly. + $this->assertEquals( (int) $_ENV['CONVERTKIT_API_FORM_ID'], $result['page_form']); + $this->assertEquals('before_content', $result['page_form_position']); + $this->assertEquals('h2', $result['page_form_position_element']); + $this->assertEquals(2, $result['page_form_position_element_index']); + } + + /** + * Test that kit/settings-general-get returns all per-post-type Default + * Form keys for each supported post type, exercising the dynamic + * schema generation. + * + * @since 3.4.0 + */ + public function testGetReturnsPerPostTypeDefaultFormKeys() + { + $this->populateSettings(); + + $abilities = convertkit_get_abilities(); + $result = $abilities['kit/settings-general-get']->execute_callback([]); + + foreach ( convertkit_get_supported_post_types() as $post_type ) { + $this->assertArrayHasKey($post_type . '_form', $result); + $this->assertArrayHasKey($post_type . '_form_position', $result); + $this->assertArrayHasKey($post_type . '_form_position_element', $result); + $this->assertArrayHasKey($post_type . '_form_position_element_index', $result); + $this->assertIsInt($result[ $post_type . '_form' ]); + $this->assertIsString($result[ $post_type . '_form_position' ]); + $this->assertIsString($result[ $post_type . '_form_position_element' ]); + $this->assertIsInt($result[ $post_type . '_form_position_element_index' ]); + } + } + + /** + * Test that kit/settings-general-update accepts a positive integer + * Form ID for a per-post-type Default Form setting. + * + * @since 3.4.0 + */ + public function testUpdatePageFormAcceptsPositiveInteger() + { + $abilities = convertkit_get_abilities(); + $result = $abilities['kit/settings-general-update']->execute_callback( + [ 'page_form' => 123 ] + ); + + $this->assertIsArray($result); + $this->assertSame(123, $result['page_form']); + } + + /** + * Test that kit/settings-general-update accepts `-1` as the Plugin + * Default sentinel value for a per-post-type Default Form setting. + * + * @since 3.4.0 + */ + public function testUpdatePageFormAcceptsMinusOne() + { + $abilities = convertkit_get_abilities(); + $result = $abilities['kit/settings-general-update']->execute_callback( + [ 'page_form' => -1 ] + ); + + $this->assertSame(-1, $result['page_form']); + } + + /** + * Test that kit/settings-general-update accepts `0` as the "None" + * value for a per-post-type Default Form setting. + * + * @since 3.4.0 + */ + public function testUpdatePageFormAcceptsZero() + { + $abilities = convertkit_get_abilities(); + $result = $abilities['kit/settings-general-update']->execute_callback( + [ 'page_form' => 0 ] + ); + + $this->assertSame(0, $result['page_form']); + } + + /** + * Test that kit/settings-general-update rejects a Form value below the + * schema minimum of `-1`. + * + * @since 3.4.0 + */ + public function testUpdatePageFormRejectsBelowMinimum() + { + $abilities = convertkit_get_abilities(); + $result = $abilities['kit/settings-general-update']->execute_callback( + [ 'page_form' => -99 ] + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Test that kit/settings-general-update accepts every value in the + * `_form_position` enum. + * + * @since 3.4.0 + */ + public function testUpdatePageFormPositionAcceptsEnumValues() + { + $abilities = convertkit_get_abilities(); + + foreach ( [ 'before_content', 'after_content', 'before_after_content', 'after_element' ] as $position ) { + $result = $abilities['kit/settings-general-update']->execute_callback( + [ 'page_form_position' => $position ] + ); + + $this->assertIsArray($result, "Expected `$position` to be accepted."); + $this->assertSame($position, $result['page_form_position']); + } + } + + /** + * Test that kit/settings-general-update rejects a + * `_form_position` value outside the enum. + * + * @since 3.4.0 + */ + public function testUpdatePageFormPositionRejectsUnknownValue() + { + $abilities = convertkit_get_abilities(); + $result = $abilities['kit/settings-general-update']->execute_callback( + [ 'page_form_position' => 'middle' ] + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Test that kit/settings-general-update accepts every value in the + * `_form_position_element` enum. + * + * @since 3.4.0 + */ + public function testUpdatePageFormPositionElementAcceptsEnumValues() + { + $abilities = convertkit_get_abilities(); + + foreach ( [ 'p', 'h2', 'h3', 'h4', 'h5', 'h6', 'img' ] as $element ) { + $result = $abilities['kit/settings-general-update']->execute_callback( + [ 'page_form_position_element' => $element ] + ); + + $this->assertIsArray($result, "Expected `$element` to be accepted."); + $this->assertSame($element, $result['page_form_position_element']); + } + } + + /** + * Test that kit/settings-general-update rejects `h1` for + * `_form_position_element`. h1 is deliberately excluded + * from the enum because it's the post title. + * + * @since 3.4.0 + */ + public function testUpdatePageFormPositionElementRejectsH1() + { + $abilities = convertkit_get_abilities(); + $result = $abilities['kit/settings-general-update']->execute_callback( + [ 'page_form_position_element' => 'h1' ] + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Test the acceptable range for + * `_form_position_element_index` (1..999 inclusive). + * + * @since 3.4.0 + */ + public function testUpdatePageFormPositionElementIndexRange() + { + $abilities = convertkit_get_abilities(); + + // Boundary values are accepted. + foreach ( [ 1, 999 ] as $index ) { + $result = $abilities['kit/settings-general-update']->execute_callback( + [ 'page_form_position_element_index' => $index ] + ); + $this->assertIsArray($result, "Expected index `$index` to be accepted."); + $this->assertSame($index, $result['page_form_position_element_index']); + } + + // Out-of-range values are rejected. + foreach ( [ 0, 1000, -1 ] as $index ) { + $result = $abilities['kit/settings-general-update']->execute_callback( + [ 'page_form_position_element_index' => $index ] + ); + $this->assertInstanceOf(\WP_Error::class, $result, "Expected index `$index` to be rejected."); + } + } + + /** + * Test that a partial update to `page_form` preserves other settings + * — including other per-post-type keys and unrelated settings. + * + * @since 3.4.0 + */ + public function testUpdatePartialPageFormPreservesOtherSettings() + { + // Seed a full settings state. + $this->populateSettings(); + + $abilities = convertkit_get_abilities(); + + // Update only page_form. + $result = $abilities['kit/settings-general-update']->execute_callback( + [ 'page_form' => 999 ] + ); + + $this->assertSame(999, $result['page_form']); + + // Confirm unrelated settings are unchanged. + $this->assertEquals('on', $result['debug']); + + // Confirm the other page_form_* keys are unchanged. + $this->assertEquals('before_content', $result['page_form_position']); + $this->assertEquals('h2', $result['page_form_position_element']); + $this->assertEquals(2, $result['page_form_position_element_index']); + + // Confirm the post_form_* keys are unchanged. + $this->assertEquals( (int) $_ENV['CONVERTKIT_API_FORM_ID'], $result['post_form']); + } + + /** + * Test that update -> get round-trip returns the updated per-post-type + * Default Form value. + * + * @since 3.4.0 + */ + public function testUpdatePageFormRoundTrip() + { + $abilities = convertkit_get_abilities(); + + $abilities['kit/settings-general-update']->execute_callback( + [ 'page_form' => 555 ] + ); + + $get_result = $abilities['kit/settings-general-get']->execute_callback([]); + + $this->assertSame(555, $get_result['page_form']); + } + + /** + * Test that a custom post type added via the + * `convertkit_supported_post_types` filter is dynamically added to + * the schema, and that get/update work against its keys. + * + * This proves the schema iterates the filter's supported post types + * at request time rather than using a hardcoded list. + * + * @since 3.4.0 + */ + public function testGetIncludesCustomPostTypeRegisteredViaFilter() + { + // Register the CPT with WordPress first so the filter's output + // is meaningful (get_post_type_object() succeeds downstream). + register_post_type( + 'kit_test_cpt', + [ + 'public' => true, + 'label' => 'Kit Test CPT', + ] + ); + + // Append the CPT to the supported post types. + $filter = function ( $post_types ) { + $post_types[] = 'kit_test_cpt'; + return $post_types; + }; + add_filter('convertkit_supported_post_types', $filter); + + try { + $abilities = convertkit_get_abilities(); + + // Get should include the CPT's four Default Form keys. + $get_result = $abilities['kit/settings-general-get']->execute_callback([]); + $this->assertArrayHasKey('kit_test_cpt_form', $get_result); + $this->assertArrayHasKey('kit_test_cpt_form_position', $get_result); + $this->assertArrayHasKey('kit_test_cpt_form_position_element', $get_result); + $this->assertArrayHasKey('kit_test_cpt_form_position_element_index', $get_result); + + // Update should accept them. + $update_result = $abilities['kit/settings-general-update']->execute_callback( + [ + 'kit_test_cpt_form' => 777, + 'kit_test_cpt_form_position' => 'after_content', + 'kit_test_cpt_form_position_element' => 'h3', + 'kit_test_cpt_form_position_element_index' => 5, + ] + ); + $this->assertIsArray($update_result); + $this->assertSame(777, $update_result['kit_test_cpt_form']); + $this->assertSame('after_content', $update_result['kit_test_cpt_form_position']); + $this->assertSame('h3', $update_result['kit_test_cpt_form_position_element']); + $this->assertSame(5, $update_result['kit_test_cpt_form_position_element_index']); + } finally { + remove_filter('convertkit_supported_post_types', $filter); + unregister_post_type('kit_test_cpt'); + } + } + + /** + * Test that kit/settings-general-update rejects a key that looks + * post-type-shaped but is not a supported post type — proving + * `additionalProperties: false` is enforced against the dynamic schema. + * + * @since 3.4.0 + */ + public function testUpdateRejectsUnsupportedPostTypeFormKey() + { + $abilities = convertkit_get_abilities(); + $result = $abilities['kit/settings-general-update']->execute_callback( + [ 'unsupportedcpt_form' => 123 ] + ); + + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Test that kit/settings-general-update updates the settings. + * + * @since 3.4.0 + */ + public function testUpdateSettings() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-general-update']->execute_callback( + [ + 'recaptcha_site_key' => '12345', + 'debug' => '', + 'no_scripts' => 'on', + 'no_css' => 'on', + 'no_add_new_button' => 'on', + 'usage_tracking' => 'on', + ] + ); + + // Confirm secret keys are not returned. + $this->assertArrayNotHasKey('access_token', $result); + $this->assertArrayNotHasKey('refresh_token', $result); + $this->assertArrayNotHasKey('token_expires', $result); + $this->assertArrayNotHasKey('api_key', $result); + $this->assertArrayNotHasKey('api_secret', $result); + $this->assertArrayNotHasKey('recaptcha_secret_key', $result); + + // Confirm expected settings are returned. + $this->assertArrayHasKey('non_inline_form', $result); + $this->assertArrayHasKey('non_inline_form_honor_none_setting', $result); + $this->assertArrayHasKey('non_inline_form_limit_per_session', $result); + $this->assertArrayHasKey('recaptcha_site_key', $result); + $this->assertArrayHasKey('recaptcha_minimum_score', $result); + $this->assertArrayHasKey('debug', $result); + $this->assertArrayHasKey('no_scripts', $result); + $this->assertArrayHasKey('no_css', $result); + $this->assertArrayHasKey('no_add_new_button', $result); + $this->assertArrayHasKey('usage_tracking', $result); + + // Confirm settings are updated. + $this->assertEquals('12345', $result['recaptcha_site_key']); + $this->assertEquals('', $result['debug']); + $this->assertEquals('on', $result['no_scripts']); + $this->assertEquals('on', $result['no_css']); + $this->assertEquals('on', $result['no_add_new_button']); + $this->assertEquals('on', $result['usage_tracking']); + } + + /** + * Test that kit/settings-general-update returns an error if an invalid key is provided. + * + * @since 3.4.0 + */ + public function testUpdateSettingsWithInvalidKeyReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-general-update']->execute_callback([ 'invalid_key' => 'invalid_value' ]); + } + + /** + * Test that kit/settings-general-update returns an error if a secret key is provided. + * + * @since 3.4.0 + */ + public function testUpdateSettingsWithSecretKeyReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-general-update']->execute_callback([ 'access_token' => 'invalid_value' ]); + } + + /** + * Populate the settings with some sensible values for testing. + * + * @since 3.4.0 + */ + private function populateSettings() + { + update_option( + self::SETTINGS_NAME, + [ + 'access_token' => $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], + 'refresh_token' => $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'], + 'debug' => 'on', + 'no_scripts' => '', + 'no_css' => '', + 'no_add_new_button' => '', + 'usage_tracking' => '', + 'post_form' => (int) $_ENV['CONVERTKIT_API_FORM_ID'], + 'post_form_position' => 'after_content', + 'post_form_position_element' => 'p', + 'post_form_position_element_index' => 1, + 'page_form' => (int) $_ENV['CONVERTKIT_API_FORM_ID'], + 'page_form_position' => 'before_content', + 'page_form_position_element' => 'h2', + 'page_form_position_element_index' => 2, + 'article_form' => (int) $_ENV['CONVERTKIT_API_FORM_ID'], + 'product_form' => (int) $_ENV['CONVERTKIT_API_FORM_ID'], + 'non_inline_form' => array(), + 'non_inline_form_honor_none_setting' => '', + 'recaptcha_site_key' => '', + 'recaptcha_secret_key' => '', + 'recaptcha_minimum_score' => '', + ] + ); + } +} diff --git a/tests/Integration/MCPSettingsRestrictContentTest.php b/tests/Integration/MCPSettingsRestrictContentTest.php new file mode 100644 index 000000000..95a68010a --- /dev/null +++ b/tests/Integration/MCPSettingsRestrictContentTest.php @@ -0,0 +1,257 @@ + \ConvertKit_MCP_Ability_Settings_Get::class, + 'kit/settings-restrict-content-update' => \ConvertKit_MCP_Ability_Settings_Update::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot manage options. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutManageOptionsCapability() + { + // Become a Subscriber (no manage_options capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that kit/settings-restrict-content-get returns the current settings. + * + * @since 3.4.0 + */ + public function testGetSettings() + { + // Populate settings. + $this->populateSettings(); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-restrict-content-get']->execute_callback([]); + + // Confirm expected settings are returned. + $this->assertArrayHasKey('permit_crawlers', $result); + $this->assertEquals('on', $result['permit_crawlers']); + $this->assertArrayHasKey('no_access_text_form', $result); + $this->assertArrayHasKey('subscribe_heading', $result); + $this->assertArrayHasKey('subscribe_text', $result); + $this->assertArrayHasKey('no_access_text', $result); + $this->assertArrayHasKey('subscribe_heading_tag', $result); + $this->assertArrayHasKey('subscribe_text_tag', $result); + $this->assertArrayHasKey('require_tag_login', $result); + $this->assertEquals('on', $result['require_tag_login']); + $this->assertArrayHasKey('no_access_text_tag', $result); + $this->assertArrayHasKey('subscribe_button_label', $result); + $this->assertArrayHasKey('email_text', $result); + $this->assertArrayHasKey('email_button_label', $result); + $this->assertArrayHasKey('email_heading', $result); + $this->assertArrayHasKey('email_description_text', $result); + $this->assertArrayHasKey('email_check_heading', $result); + $this->assertArrayHasKey('email_check_text', $result); + $this->assertArrayHasKey('container_css_classes', $result); + } + + /** + * Test that kit/settings-restrict-content-update updates the settings. + * + * @since 3.4.0 + */ + public function testUpdateSettings() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-restrict-content-update']->execute_callback( + [ + 'permit_crawlers' => 'on', + 'subscribe_heading' => 'Updated subscribe heading', + 'subscribe_text' => 'Updated subscribe text', + 'require_tag_login' => 'on', + 'subscribe_button_label' => 'Join now', + 'container_css_classes' => 'kit-restrict kit-restrict-custom', + ] + ); + + // Confirm expected settings are returned. + $this->assertArrayHasKey('permit_crawlers', $result); + $this->assertArrayHasKey('no_access_text_form', $result); + $this->assertArrayHasKey('subscribe_heading', $result); + $this->assertArrayHasKey('subscribe_text', $result); + $this->assertArrayHasKey('no_access_text', $result); + $this->assertArrayHasKey('subscribe_heading_tag', $result); + $this->assertArrayHasKey('subscribe_text_tag', $result); + $this->assertArrayHasKey('require_tag_login', $result); + $this->assertArrayHasKey('no_access_text_tag', $result); + $this->assertArrayHasKey('subscribe_button_label', $result); + $this->assertArrayHasKey('email_text', $result); + $this->assertArrayHasKey('email_button_label', $result); + $this->assertArrayHasKey('email_heading', $result); + $this->assertArrayHasKey('email_description_text', $result); + $this->assertArrayHasKey('email_check_heading', $result); + $this->assertArrayHasKey('email_check_text', $result); + $this->assertArrayHasKey('container_css_classes', $result); + + // Confirm settings are updated. + $this->assertEquals('on', $result['permit_crawlers']); + $this->assertEquals('Updated subscribe heading', $result['subscribe_heading']); + $this->assertEquals('Updated subscribe text', $result['subscribe_text']); + $this->assertEquals('on', $result['require_tag_login']); + $this->assertEquals('Join now', $result['subscribe_button_label']); + $this->assertEquals('kit-restrict kit-restrict-custom', $result['container_css_classes']); + } + + /** + * Test that kit/settings-restrict-content-update returns an error if an invalid key is provided. + * + * @since 3.4.0 + */ + public function testUpdateSettingsWithInvalidKeyReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-restrict-content-update']->execute_callback([ 'invalid_key' => 'invalid_value' ]); + } + + /** + * Populate the settings with some sensible values for testing. + * + * @since 3.4.0 + */ + private function populateSettings() + { + update_option( + self::SETTINGS_NAME, + [ + 'permit_crawlers' => 'on', + 'no_access_text_form' => 'No access (form).', + 'subscribe_heading' => 'Read with a premium subscription', + 'subscribe_text' => 'Only available to premium subscribers.', + 'no_access_text' => 'No access (product).', + 'subscribe_heading_tag' => 'Subscribe to keep reading', + 'subscribe_text_tag' => 'Free but only available to subscribers.', + 'require_tag_login' => 'on', + 'no_access_text_tag' => 'No access (tag).', + 'subscribe_button_label' => 'Subscribe', + 'email_text' => 'Already subscribed?', + 'email_button_label' => 'Log in', + 'email_heading' => 'Log in to read this post', + 'email_description_text' => 'We\'ll email you a magic code to log you in.', + 'email_check_heading' => 'We just emailed you a log in code', + 'email_check_text' => 'Enter the code below to finish logging in', + 'container_css_classes' => '', + ] + ); + } +} diff --git a/tests/Integration/ShortcodePostHelperTest.php b/tests/Integration/ShortcodePostHelperTest.php new file mode 100644 index 000000000..0188d10ea --- /dev/null +++ b/tests/Integration/ShortcodePostHelperTest.php @@ -0,0 +1,446 @@ +postID = $this->createPost(); + } + + /** + * Performs actions after each test. + * + * @since 3.4.0 + */ + public function tearDown(): void + { + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * Test that the find() method returns the correct shortcode indicies and attributes. + * + * @since 3.4.0 + */ + public function testFind() + { + // Find the shortcode. + $shortcodes = \ConvertKit_Shortcode_Post_Helper::find( $this->postID, 'convertkit_form' ); + + $this->assertIsArray( $shortcodes ); + $this->assertCount( 2, $shortcodes ); + + // Assert first matching shortcode indicies and attributes are correct. + $this->assertEquals( 0, $shortcodes[0]['occurrence_index'] ); + $this->assertEquals( $_ENV['CONVERTKIT_API_FORM_ID'], $shortcodes[0]['attrs']['form'] ); + + // Assert second matching shortcode indicies and attributes are correct. + $this->assertEquals( 1, $shortcodes[1]['occurrence_index'] ); + $this->assertEquals( $_ENV['CONVERTKIT_API_FORM_ID'], $shortcodes[1]['attrs']['form'] ); + } + + /** + * Test that the find() method returns false when no shortcodes match the given shortcode tag. + * + * @since 3.4.0 + */ + public function testFindWhenNoShortcodesMatch() + { + $this->assertFalse(\ConvertKit_Shortcode_Post_Helper::find( $this->postID, 'fake_shortcode' )); + } + + /** + * Test that the find() method returns a WP_Error when the post does not exist. + * + * @since 3.4.0 + */ + public function testFindWhenPostDoesNotExist() + { + $this->assertInstanceOf(\WP_Error::class, \ConvertKit_Shortcode_Post_Helper::find( 999999, 'convertkit_form' )); + } + + /** + * Test that the insert() method inserts a new shortcode at the beginning of the content + * when the position is set to prepend. + * + * @since 3.4.0 + */ + public function testInsertPrepend() + { + $result = \ConvertKit_Shortcode_Post_Helper::insert( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'prepend' + ); + + // Confirm result is an array and the post ID is correct. + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the shortcode is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringStartsWith( '[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content ); + } + + /** + * Test that the insert() method inserts a new shortcode at the end of the content + * when the position is set to append. + * + * @since 3.4.0 + */ + public function testInsertAppend() + { + $result = \ConvertKit_Shortcode_Post_Helper::insert( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'append' + ); + + // Confirm result is an array and the post ID is correct. + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the shortcode is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringEndsWith( '[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content ); + } + + /** + * Test that the insert() method inserts a new shortcode at the specified index position. + * + * @since 3.4.0 + */ + public function testInsertIndex() + { + $result = \ConvertKit_Shortcode_Post_Helper::insert( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 1 + ); + + // Confirm result is an array and the post ID is correct. + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the shortcode is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringContainsString( "Item #1\n\n[convertkit_form form=\"" . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content ); + } + + /** + * Test that the insert() method inserts a new shortcode at end of the content when + * the index is out of bounds. + * + * @since 3.4.0 + */ + public function testInsertIndexOutOfBounds() + { + $result = \ConvertKit_Shortcode_Post_Helper::insert( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 100 + ); + + // Confirm result is an array and the post ID is correct. + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the shortcode is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringEndsWith( '[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content ); + } + + /** + * Test that the insert() method inserts a new shortcode at the specified index position. + * + * @since 3.4.0 + */ + public function testInsertIndexZero() + { + $result = \ConvertKit_Shortcode_Post_Helper::insert( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 0 + ); + + // Confirm result is an array and the post ID is correct. + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + // Confirm content has been updated and the shortcode is inserted at the correct position. + $post = get_post($this->postID); + $this->assertStringStartsWith( '[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content ); + } + + /** + * Test that the insert() method returns a WP_Error when the index is negative. + * + * @since 3.4.0 + */ + public function testInsertIndexNegative() + { + $result = \ConvertKit_Shortcode_Post_Helper::insert( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: -1 + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the insert() method returns a WP_Error when the post does not exist. + * + * @since 3.4.0 + */ + public function testInsertWhenPostDoesNotExist() + { + $result = \ConvertKit_Shortcode_Post_Helper::insert( + post_id: 999999, + shortcode_tag: 'convertkit_form', + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ], + position: 'index', + index: 0 + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the update() method updates the attributes of an existing shortcode. + * + * @since 3.4.0 + */ + public function testUpdate() + { + $result = \ConvertKit_Shortcode_Post_Helper::update( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + occurrence_index: 0, + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + $result = \ConvertKit_Shortcode_Post_Helper::update( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + occurrence_index: 1, + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] + ); + + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + } + + /** + * Test that the update() method returns a WP_Error when the occurrence index is out of bounds. + * + * @since 3.4.0 + */ + public function testUpdateWhenOccurrenceIndexIsOutOfBounds() + { + $result = \ConvertKit_Shortcode_Post_Helper::update( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + occurrence_index: 999, + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the update() method returns a WP_Error when the post does not exist. + * + * @since 3.4.0 + */ + public function testUpdateWhenPostDoesNotExist() + { + $result = \ConvertKit_Shortcode_Post_Helper::update( + post_id: 999999, + shortcode_tag: 'convertkit_form', + occurrence_index: 0, + attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ] + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the delete() method deletes an existing shortcode. + * + * @since 3.4.0 + */ + public function testDelete() + { + $result = \ConvertKit_Shortcode_Post_Helper::delete( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + occurrence_index: 1 + ); + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + + $result = \ConvertKit_Shortcode_Post_Helper::delete( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + occurrence_index: 0 + ); + $this->assertIsArray( $result ); + $this->assertEquals( $this->postID, $result['post_id'] ); + } + + /** + * Test that the delete() method returns a WP_Error when the occurrence index is out of bounds. + * + * @since 3.4.0 + */ + public function testDeleteWhenOccurrenceIndexIsOutOfBounds() + { + $result = \ConvertKit_Shortcode_Post_Helper::delete( + post_id: $this->postID, + shortcode_tag: 'convertkit_form', + occurrence_index: 999 + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Test that the delete() method returns a WP_Error when the post does not exist. + * + * @since 3.4.0 + */ + public function testDeleteWhenPostDoesNotExist() + { + $result = \ConvertKit_Shortcode_Post_Helper::delete( + post_id: 999999, + shortcode_tag: 'convertkit_form', + occurrence_index: 0 + ); + $this->assertInstanceOf(\WP_Error::class, $result ); + } + + /** + * Mocks a post for testing. + * + * @since 3.4.0 + * @return int + */ + private function createPost() + { + // Create a Post with the given shortcode. + return $this->factory->post->create( + [ + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Shortcode Post', + 'post_content' => 'Item #1 + +

Item #1

+ +Item #2: Adhaésionés altéram improbis mi pariendarum sit stulti triarium + +
Image #1
+ +

Item #2

+ +[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"] + +Item #3 + +
Image #2
+ +[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"] + +

Item #1

+ +Item #4 + +

Item #1

+ +Item #5 + +

Item #2

+ +

Item #2

', + ] + ); + } +} diff --git a/tests/Support/Helper/KitPlugin.php b/tests/Support/Helper/KitPlugin.php index 473622aa2..a2717ca62 100644 --- a/tests/Support/Helper/KitPlugin.php +++ b/tests/Support/Helper/KitPlugin.php @@ -575,6 +575,7 @@ public function resetKitPlugin($I) $I->dontHaveOptionInDatabase('_wp_convertkit_settings'); $I->dontHaveOptionInDatabase('_wp_convertkit_settings_restrict_content'); $I->dontHaveOptionInDatabase('_wp_convertkit_settings_broadcasts'); + $I->dontHaveOptionInDatabase('_wp_convertkit_settings_mcp'); $I->dontHaveOptionInDatabase('convertkit_version'); // Resources. diff --git a/tests/Support/Helper/KitRestrictContent.php b/tests/Support/Helper/KitRestrictContent.php index 700a1e03e..ca162b4a9 100644 --- a/tests/Support/Helper/KitRestrictContent.php +++ b/tests/Support/Helper/KitRestrictContent.php @@ -43,6 +43,21 @@ public function loadKitSettingsRestrictContentScreen($I) $I->checkNoWarningsAndNoticesOnScreen($I); } + /** + * Helper method to load the Plugin's Settings > MCP screen. + * + * @since 3.4.0 + * + * @param EndToEndTester $I EndToEndTester. + */ + public function loadKitSettingsMCPScreen($I) + { + $I->amOnAdminPage('options-general.php?page=_wp_convertkit_settings&tab=mcp'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + } + /** * Returns the expected default settings for Restricted Content. * diff --git a/tests/Support/Helper/WPRestAPI.php b/tests/Support/Helper/WPRestAPI.php new file mode 100644 index 000000000..479a50a35 --- /dev/null +++ b/tests/Support/Helper/WPRestAPI.php @@ -0,0 +1,96 @@ +{yourFunctionName}. + * + * @since 3.4.0 + */ +class WPRestAPI extends \Codeception\Module +{ + /** + * Check that the given route is registered in the REST API. + * + * @since 3.4.0 + * + * @param EndToEndTester $I EndToEndTester. + * @param string $route Route. + */ + public function hasRoute($I, $route) + { + $I->assertTrue( in_array( $route, $this->getRoutes(), true ) ); + } + + /** + * Check that the given route is not registered in the REST API. + * + * @since 3.4.0 + * + * @param EndToEndTester $I EndToEndTester. + * @param string $route Route. + */ + public function doesNotHaveRoute($I, $route) + { + $I->assertFalse( in_array( $route, $this->getRoutes(), true ) ); + } + + /** + * Call a REST API endpoint. + * + * @since 3.4.0 + * + * @param string $endpoint Endpoint. + * @param string $authorizationHeader Authorization Header. + * @param string $method Method. + * @param array $body Body. + * @return array + */ + public function callRestEndpoint( $endpoint, $authorizationHeader, $method = 'GET', $body = null ) { + $url = $_ENV['WORDPRESS_URL'] . '/wp-json' . $endpoint; + + $args = [ + 'method' => $method, + 'headers' => [ + 'Authorization' => $authorizationHeader, + 'Content-Type' => 'application/json', + ], + 'timeout' => 10, + ]; + + // Only attach a body when there's something to send. WP's HTTP layer + // calls http_build_query() on `body` when the method is GET, which + // fails if we've already JSON-encoded the value to a string. + if ( ! empty( $body ) ) { + $args['body'] = wp_json_encode( $body ); + } + + $response = wp_remote_request( $url, $args ); + + if ( is_wp_error( $response ) ) { + return [ + 'status' => 0, + 'body' => $response->get_error_message(), + ]; + } + + return [ + 'status' => wp_remote_retrieve_response_code( $response ), + 'body' => json_decode( wp_remote_retrieve_body( $response ), true ), + ]; + } + + /** + * Get the routes registered in the REST API. + * + * @since 3.4.0 + * + * @return array + */ + private function getRoutes() + { + $response = wp_remote_get( rest_url() ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + return array_keys( $body['routes'] ?? [] ); + } +} diff --git a/wp-convertkit.php b/wp-convertkit.php index caead13e1..b9c174861 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -30,6 +30,7 @@ define( 'CONVERTKIT_PLUGIN_VERSION', '3.3.6' ); define( 'CONVERTKIT_OAUTH_CLIENT_ID', 'HXZlOCj-K5r0ufuWCtyoyo3f688VmMAYSsKg1eGvw0Y' ); define( 'CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI', 'https://app.kit.com/wordpress/redirect' ); +define( 'CONVERTKIT_MCP_APP_NAME', 'Kit WordPress Plugin: MCP Server' ); // Load shared classes, if they have not been included by another Kit Plugin. if ( ! trait_exists( 'ConvertKit_API_Traits' ) && ! trait_exists( 'ConvertKit_API\ConvertKit_API_Traits' ) ) { @@ -78,6 +79,7 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-resource-tags.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-settings.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-settings-broadcasts.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-settings-mcp.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-settings-restrict-content.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-setup.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/class-convertkit-shortcodes.php'; @@ -96,9 +98,33 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-form-builder-field-name.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-form-builder-field-custom.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/class-convertkit-block-product.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/helpers/class-convertkit-block-post-helper.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/helpers/class-convertkit-content-post-helper.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-form-link.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-product-link.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp-ability.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-forms.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-tags.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-landing-pages.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-products.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-get.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-update.php'; +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'; @@ -130,6 +156,7 @@ require_once CONVERTKIT_PLUGIN_PATH . '/admin/section/class-convertkit-admin-section-broadcasts.php'; require_once CONVERTKIT_PLUGIN_PATH . '/admin/section/class-convertkit-admin-section-form-entries.php'; require_once CONVERTKIT_PLUGIN_PATH . '/admin/section/class-convertkit-admin-section-general.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/admin/section/class-convertkit-admin-section-mcp.php'; require_once CONVERTKIT_PLUGIN_PATH . '/admin/section/class-convertkit-admin-section-oauth.php'; require_once CONVERTKIT_PLUGIN_PATH . '/admin/section/class-convertkit-admin-section-restrict-content.php'; require_once CONVERTKIT_PLUGIN_PATH . '/admin/section/class-convertkit-admin-section-tools.php';