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 c308a1bd0..35d78d32f 100644 --- a/includes/blocks/class-convertkit-block.php +++ b/includes/blocks/class-convertkit-block.php @@ -68,10 +68,10 @@ public function register_abilities( $abilities ) { return array_merge( $abilities, array( - new ConvertKit_MCP_Ability_Content_List( $this ), - new ConvertKit_MCP_Ability_Content_Insert( $this ), - new ConvertKit_MCP_Ability_Content_Update( $this ), - new ConvertKit_MCP_Ability_Content_Delete( $this ), + '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 ), ) ); @@ -105,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/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php index fc21b383a..c0081e9e9 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php @@ -51,7 +51,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'Delete an existing %s element from a post', 'convertkit' ), + __( 'Delete Existing %s from a Post, Page or Custom Post', 'convertkit' ), $this->block->get_title() ); @@ -67,10 +67,9 @@ public function get_label() { public function get_description() { return sprintf( - /* translators: 1: block full name e.g. convertkit/form, 2: block title */ - __( 'Removes a single occurrence of the %1$s (%2$s) element from the given post.', 'convertkit' ), - 'convertkit/' . $this->block->get_name(), - $this->block->get_title() + /* 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() ); } 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 index 5c14151f7..d705ec571 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php @@ -42,7 +42,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'Insert a %s element into a post', 'convertkit' ), + __( 'Insert %s into a Page, Post or Custom Post', 'convertkit' ), $this->block->get_title() ); @@ -59,9 +59,8 @@ public function get_description() { return sprintf( /* translators: 1: block full name e.g. convertkit/form, 2: block title */ - __( 'Inserts a new %1$s (%2$s) element into the given post\'s content. The element can be appended (default), prepended, or positioned relative to an existing element using a zero-based index.', 'convertkit' ), - 'convertkit/' . $this->block->get_name(), - $this->block->get_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() ); } 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 index 398b51db5..3e05e3bc9 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php @@ -60,8 +60,8 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'List %s elements in a post', 'convertkit' ), - $this->block->get_title() + __( 'List %s in a Post, Page or Custom Post', 'convertkit' ), + $this->block->get_title_plural() ); } @@ -76,10 +76,9 @@ public function get_label() { public function get_description() { return sprintf( - /* translators: 1: block full name e.g. convertkit/form, 2: block title */ - __( 'Lists every occurrence of the %1$s (%2$s) element in the given post, including each occurrence\'s zero-based index and current attribute values.', 'convertkit' ), - 'convertkit/' . $this->block->get_name(), - $this->block->get_title() + /* 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() ); } 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 index d7c73ca47..a614f66c9 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php @@ -51,7 +51,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'Update an existing %s element in a post', 'convertkit' ), + __( 'Update Existing %s in a Page, Post or Custom Post', 'convertkit' ), $this->block->get_title() ); @@ -67,10 +67,9 @@ public function get_label() { public function get_description() { return sprintf( - /* translators: 1: block full name e.g. convertkit/form, 2: block title */ - __( 'Updates the attributes of a single occurrence of the %1$s (%2$s) element in the given post. By default the provided attributes are merged into the existing attributes.', 'convertkit' ), - 'convertkit/' . $this->block->get_name(), - $this->block->get_title() + /* 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() ); } diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php index cce7a4d43..539ef3e36 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php @@ -165,6 +165,9 @@ private function get_input_schema_property_type( $field ) { switch ( $type ) { case 'resource': + case 'text': + case 'color': + case 'select': return 'string'; case 'number': @@ -174,7 +177,8 @@ private function get_input_schema_property_type( $field ) { return 'boolean'; default: - return $type; + // Unknown field type — fall back to string. + return 'string'; } } 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.

+', + ) + ); + } +}