diff --git a/projects/packages/blaze/changelog/add-ads-1036-campaign-preparer b/projects/packages/blaze/changelog/add-ads-1036-campaign-preparer new file mode 100644 index 000000000000..f5946d95a5ce --- /dev/null +++ b/projects/packages/blaze/changelog/add-ads-1036-campaign-preparer @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Blaze: Extract campaign preparation into a reusable preparer. diff --git a/projects/packages/blaze/src/abilities/class-blaze-abilities.php b/projects/packages/blaze/src/abilities/class-blaze-abilities.php index 1b04ff714178..63279936d897 100644 --- a/projects/packages/blaze/src/abilities/class-blaze-abilities.php +++ b/projects/packages/blaze/src/abilities/class-blaze-abilities.php @@ -22,6 +22,7 @@ namespace Automattic\Jetpack\Blaze\Abilities; use Automattic\Jetpack\Blaze; +use Automattic\Jetpack\Blaze\Campaign_Preparer; use Automattic\Jetpack\Connection\Manager as Jetpack_Connection; use Automattic\Jetpack\WP_Abilities\Registrar; use WP_REST_Request; @@ -32,11 +33,9 @@ */ class Blaze_Abilities extends Registrar { - const CATEGORY_SLUG = 'blaze-ads'; - const ABILITY_LIST_CAMPAIGNS = 'blaze-ads/list-campaigns'; - const ABILITY_PREPARE_CAMPAIGN = 'blaze-ads/prepare-campaign'; - private const DEFAULT_BUDGET_TOTAL = 50.0; - private const DEFAULT_DURATION_DAYS = 7; + const CATEGORY_SLUG = 'blaze-ads'; + const ABILITY_LIST_CAMPAIGNS = 'blaze-ads/list-campaigns'; + const ABILITY_PREPARE_CAMPAIGN = 'blaze-ads/prepare-campaign'; /** * Slugs we own — used by `opt_into_woo_mcp` and the double-register guard. @@ -219,7 +218,7 @@ public static function get_abilities(): array { ), self::ABILITY_PREPARE_CAMPAIGN => array( 'label' => __( 'Prepare a Blaze campaign', 'jetpack-blaze' ), - 'description' => __( 'Prepare a Blaze advertising campaign proposal for an existing post or product on the site. The ability does not write to the DSP itself. It takes a target plus optional natural-language goal, budget, duration, copy, image, and audience overrides; derives sensible defaults from the target post; bundles the result into a prefill payload; and returns a deep-link the merchant clicks to review and submit in the existing Blaze UI. The merchant reviews, accepts payment / T&C, and submits from inside the Blaze UI — that\'s where the actual DSP write happens.', 'jetpack-blaze' ), + 'description' => __( 'Prepare a Blaze advertising campaign proposal for an existing post or product on the site. The ability does not write to the DSP itself. It takes a target plus optional natural-language goal, budget, duration, copy, image, and limited audience overrides; derives sensible defaults from the target post; bundles the result into a prefill payload; and returns a deep-link the merchant clicks to review and submit in the existing Blaze UI. Public audience overrides are limited to country and language codes. Device targeting and interest/topic targeting are not public MCP inputs in v1 because they depend on DSP-supported device IDs and IAB category mappings; use the goal/copy fields for intent and let Blaze defaults or the review UI handle those settings. The merchant reviews, accepts payment / T&C, and submits from inside the Blaze UI — that\'s where the actual DSP write happens.', 'jetpack-blaze' ), 'input_schema' => array( 'type' => 'object', 'required' => array( 'target_urn' ), @@ -275,7 +274,7 @@ public static function get_abilities(): array { ), 'languages' => array( 'type' => 'array', - 'description' => __( 'Optional ISO 639-1 language codes to target (e.g. ["en", "es"]). Defaults to all languages when omitted; pass a non-empty array to narrow targeting.', 'jetpack-blaze' ), + 'description' => __( 'Optional ISO 639-1 language codes supported by Blaze/DSP (e.g. ["en", "es"]). Infer from the user\'s natural language request when clear, but omit when unsure or unsupported. Defaults to all languages when omitted; the merchant can adjust language targeting in the Blaze review UI.', 'jetpack-blaze' ), 'items' => array( 'type' => 'string', 'minLength' => 2, @@ -284,7 +283,7 @@ public static function get_abilities(): array { ), 'countries' => array( 'type' => 'array', - 'description' => __( 'Optional ISO 3166-1 alpha-2 country codes to target (e.g. ["US", "GB"]). Defaults to worldwide when omitted; pass a non-empty array to limit reach to those countries.', 'jetpack-blaze' ), + 'description' => __( 'Optional ISO 3166-1 alpha-2 country codes to target (e.g. ["US", "GB"]). Infer from the user\'s natural-language location request, but emit country codes rather than localized country names. Defaults to worldwide when omitted; pass a non-empty array to limit reach to those countries.', 'jetpack-blaze' ), 'items' => array( 'type' => 'string', 'minLength' => 2, @@ -408,217 +407,21 @@ public static function list_campaigns( $args = array() ) { public static function prepare_campaign( $args = array() ) { $args = is_array( $args ) ? $args : array(); - $urn = isset( $args['target_urn'] ) ? (string) $args['target_urn'] : ''; - if ( '' === $urn || ! preg_match( '/^urn:wpcom:post:\d+:(\d+)$/', $urn, $matches ) ) { - return new \WP_Error( - 'blaze_invalid_target_urn', - /* translators: %s: the malformed URN value supplied by the caller. */ - sprintf( __( 'Invalid target_urn %s. Expected format: urn:wpcom:post::.', 'jetpack-blaze' ), $urn ), - array( 'status' => 400 ) - ); + $proposal = Campaign_Preparer::prepare( $args ); + if ( is_wp_error( $proposal ) ) { + return $proposal; } - $post = get_post( (int) $matches[1] ); - if ( ! $post ) { - return new \WP_Error( - 'blaze_post_not_found', - __( 'Post referenced by target_urn does not exist on this site.', 'jetpack-blaze' ), - array( 'status' => 404 ) - ); - } - - $prefill = self::build_prefill_payload( $args, $post ); - $prefill_url = self::build_prefill_url( $post->ID, $prefill ); - - return array( - 'status' => 'pending_merchant_review', - 'message' => sprintf( - /* translators: %s: deep-link URL that opens the Blaze widget pre-populated with the prepared campaign proposal. */ - __( 'Campaign proposal prepared. The merchant must open the Blaze UI to review, accept payment / T&C, and submit. Open here: %s', 'jetpack-blaze' ), - $prefill_url - ), - 'prefill_url' => $prefill_url, - 'prefill' => $prefill, - ); - } - - /** - * Build the campaign prefill payload from the caller's MCP input and - * the resolved target post. Pure function — no I/O — so it's easy to - * test and easy for callers to inspect in the response. - * - * Caller input (`site_name`, `text_snippet`, `cta_text`, `is_evergreen`) - * overrides the post-derived defaults. Anything we can't pull from - * the post or the input is left out — the widget will fill blanks - * with its own defaults at submission time. - * - * @param array $args MCP input. - * @param \WP_Post $post The target post. - * @return array - */ - private static function build_prefill_payload( array $args, $post ): array { - $featured_image_id = (int) get_post_thumbnail_id( $post->ID ); - $featured_image_url = $featured_image_id > 0 ? wp_get_attachment_image_url( $featured_image_id, 'full' ) : ''; - $featured_image_mime = $featured_image_id > 0 ? get_post_mime_type( $featured_image_id ) : ''; - - // Sensible default snippet: post excerpt, or first ~200 chars of content stripped of HTML. - $default_snippet = (string) get_the_excerpt( $post ); - if ( '' === $default_snippet ) { - $stripped = trim( wp_strip_all_tags( (string) $post->post_content ) ); - $default_snippet = function_exists( 'mb_substr' ) ? mb_substr( $stripped, 0, 200 ) : substr( $stripped, 0, 200 ); - } - - $payload = array( - 'target_urn' => (string) $args['target_urn'], - 'type' => (string) $post->post_type, - 'site_name' => isset( $args['site_name'] ) && '' !== (string) $args['site_name'] - ? (string) $args['site_name'] - : (string) get_the_title( $post ), - 'text_snippet' => isset( $args['text_snippet'] ) && '' !== (string) $args['text_snippet'] - ? (string) $args['text_snippet'] - : $default_snippet, - // Default CTA varies by post type — products get a commerce-flavoured - // "Shop Now", everything else gets the neutral "Learn More". The widget - // requires a non-empty CTA to clear validation, so we always send one. - 'cta_text' => isset( $args['cta_text'] ) && '' !== (string) $args['cta_text'] - ? (string) $args['cta_text'] - : ( 'product' === (string) $post->post_type ? 'Shop Now' : 'Learn More' ), - 'target_url' => (string) get_permalink( $post ), - 'budget' => array( - 'mode' => 'total', - 'amount' => (float) ( $args['budget_total'] ?? self::DEFAULT_BUDGET_TOTAL ), - 'currency' => self::get_site_currency(), - ), - 'duration_days' => (int) ( $args['duration_days'] ?? self::DEFAULT_DURATION_DAYS ), - 'is_evergreen' => isset( $args['is_evergreen'] ) ? (bool) $args['is_evergreen'] : true, - 'objective' => 'VIEWS', + return array_merge( + $proposal, + array( + 'message' => sprintf( + /* translators: %s: deep-link URL that opens the Blaze widget pre-populated with the prepared campaign proposal. */ + __( 'Campaign proposal prepared. The merchant must open the Blaze UI to review, accept payment / T&C, and submit. Open here: %s', 'jetpack-blaze' ), + $proposal['prefill_url'] + ), + ) ); - - if ( isset( $args['goal'] ) && '' !== (string) $args['goal'] ) { - $payload['goal'] = (string) $args['goal']; - } - if ( isset( $args['revision_instruction'] ) && '' !== (string) $args['revision_instruction'] ) { - $payload['revision_instruction'] = (string) $args['revision_instruction']; - } - - if ( isset( $args['main_image_url'] ) && '' !== (string) $args['main_image_url'] ) { - $payload['main_image'] = array( - 'url' => (string) $args['main_image_url'], - 'mime_type' => isset( $args['main_image_mime_type'] ) && '' !== (string) $args['main_image_mime_type'] - ? (string) $args['main_image_mime_type'] - : 'image/jpeg', - ); - } elseif ( '' !== $featured_image_url ) { - $payload['main_image'] = array( - 'url' => $featured_image_url, - 'mime_type' => $featured_image_mime ? $featured_image_mime : 'image/jpeg', - ); - } - - // Optional targeting overrides. Pass-through normalisation only — the - // widget resolves country codes to its internal geo records and the - // language codes map straight to the language picker. Empty arrays - // are dropped so the widget keeps its "all languages / worldwide" - // defaults instead of being forced to an empty selection. - if ( isset( $args['languages'] ) && is_array( $args['languages'] ) ) { - $languages = array_values( - array_filter( - array_map( 'strtolower', array_map( 'strval', $args['languages'] ) ), - static function ( $code ) { - return '' !== $code; - } - ) - ); - if ( ! empty( $languages ) ) { - $payload['languages'] = $languages; - } - } - if ( isset( $args['countries'] ) && is_array( $args['countries'] ) ) { - $countries = array_values( - array_filter( - array_map( 'strtoupper', array_map( 'strval', $args['countries'] ) ), - static function ( $code ) { - return 2 === strlen( $code ); - } - ) - ); - if ( ! empty( $countries ) ) { - $payload['countries'] = $countries; - } - } - - return $payload; - } - - /** - * Build the deep-link the merchant follows to land in the Blaze - * widget with the campaign form pre-populated. - * - * URL shape: `/tools.php?page=advertising&blaze_prefill=#!/advertising/posts/promote/post-/`. - * - * The base path comes from `Blaze::get_campaign_management_url()` so - * we stay aligned with how Blaze opens the promote-post flow today; - * the `blaze_prefill` query param is the Phase 2 addition the widget - * will read on load. The param goes in the query string (not the - * hash) so it reaches the SPA on initial bootstrap reliably. - * - * @param int $post_id The target post ID. - * @param array $prefill The prefill payload to encode. - * @return string - */ - private static function build_prefill_url( int $post_id, array $prefill ): string { - $base = ''; - if ( class_exists( '\Automattic\Jetpack\Blaze' ) && method_exists( '\Automattic\Jetpack\Blaze', 'get_campaign_management_url' ) ) { - $url_data = Blaze::get_campaign_management_url( $post_id ); - if ( is_array( $url_data ) && isset( $url_data['link'] ) ) { - $base = (string) $url_data['link']; - } - } - if ( '' === $base ) { - $base = admin_url( 'tools.php?page=advertising' ); - } - - $encoded = self::base64url_encode( (string) wp_json_encode( $prefill, JSON_UNESCAPED_SLASHES ) ); - $param = 'blaze_prefill=' . rawurlencode( $encoded ); - - // Insert the param before the hash if there is one; otherwise append. - $hash_pos = strpos( $base, '#' ); - if ( false === $hash_pos ) { - $separator = ( false !== strpos( $base, '?' ) ) ? '&' : '?'; - return $base . $separator . $param; - } - - $pre_hash = substr( $base, 0, $hash_pos ); - $hash = substr( $base, $hash_pos ); - $separator = ( false !== strpos( $pre_hash, '?' ) ) ? '&' : '?'; - - return $pre_hash . $separator . $param . $hash; - } - - /** - * URL-safe base64 encode (RFC 4648 §5). Avoids `+` and `/` characters - * that would otherwise need percent-encoding inside a URL. - * - * @param string $input Bytes to encode. - * @return string - */ - private static function base64url_encode( string $input ): string { - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encoding a structured prefill payload, not obfuscation. - return rtrim( strtr( base64_encode( $input ), '+/', '-_' ), '=' ); - } - - /** - * Currency code for the prefill payload. - * - * Always USD: the DSP only supports USD for billing, so reading the - * Woo store currency would just produce a misleading number for - * non-USD merchants (the DSP would re-interpret the amount as USD - * regardless). Until the DSP gains multi-currency, normalize here. - * - * @return string ISO 4217 currency code. - */ - private static function get_site_currency(): string { - return 'USD'; } /** diff --git a/projects/packages/blaze/src/class-campaign-preparer.php b/projects/packages/blaze/src/class-campaign-preparer.php new file mode 100644 index 000000000000..e6ab36dbba76 --- /dev/null +++ b/projects/packages/blaze/src/class-campaign-preparer.php @@ -0,0 +1,200 @@ +:.', 'jetpack-blaze' ), $urn ), + array( 'status' => 400 ) + ); + } + + $post = get_post( (int) $matches[1] ); + if ( ! $post ) { + return new \WP_Error( + 'blaze_post_not_found', + __( 'Post referenced by target_urn does not exist on this site.', 'jetpack-blaze' ), + array( 'status' => 404 ) + ); + } + + $prefill = self::build_prefill_payload( $args, $post ); + $prefill_url = self::build_prefill_url( $post->ID, $prefill ); + + return array( + 'status' => 'pending_merchant_review', + 'prefill_url' => $prefill_url, + 'prefill' => $prefill, + ); + } + + /** + * Build the campaign prefill payload from caller input and the target post. + * + * @param array $args Preparation input. + * @param \WP_Post $post The target post. + * @return array + */ + private static function build_prefill_payload( array $args, $post ): array { + $featured_image_id = (int) get_post_thumbnail_id( $post->ID ); + $featured_image_url = $featured_image_id > 0 ? wp_get_attachment_image_url( $featured_image_id, 'full' ) : ''; + $featured_image_mime = $featured_image_id > 0 ? get_post_mime_type( $featured_image_id ) : ''; + + $default_snippet = (string) get_the_excerpt( $post ); + if ( '' === $default_snippet ) { + $stripped = trim( wp_strip_all_tags( (string) $post->post_content ) ); + $default_snippet = function_exists( 'mb_substr' ) ? mb_substr( $stripped, 0, 200 ) : substr( $stripped, 0, 200 ); + } + + $payload = array( + 'target_urn' => (string) $args['target_urn'], + 'type' => (string) $post->post_type, + 'site_name' => isset( $args['site_name'] ) && '' !== (string) $args['site_name'] + ? (string) $args['site_name'] + : (string) get_the_title( $post ), + 'text_snippet' => isset( $args['text_snippet'] ) && '' !== (string) $args['text_snippet'] + ? (string) $args['text_snippet'] + : $default_snippet, + 'cta_text' => isset( $args['cta_text'] ) && '' !== (string) $args['cta_text'] + ? (string) $args['cta_text'] + : ( 'product' === (string) $post->post_type ? 'Shop Now' : 'Learn More' ), + 'target_url' => (string) get_permalink( $post ), + 'budget' => array( + 'mode' => 'total', + 'amount' => (float) ( $args['budget_total'] ?? self::DEFAULT_BUDGET_TOTAL ), + 'currency' => self::get_site_currency(), + ), + 'duration_days' => (int) ( $args['duration_days'] ?? self::DEFAULT_DURATION_DAYS ), + 'is_evergreen' => isset( $args['is_evergreen'] ) ? (bool) $args['is_evergreen'] : true, + 'objective' => 'VIEWS', + ); + + if ( isset( $args['goal'] ) && '' !== (string) $args['goal'] ) { + $payload['goal'] = (string) $args['goal']; + } + if ( isset( $args['revision_instruction'] ) && '' !== (string) $args['revision_instruction'] ) { + $payload['revision_instruction'] = (string) $args['revision_instruction']; + } + + if ( isset( $args['main_image_url'] ) && '' !== (string) $args['main_image_url'] ) { + $payload['main_image'] = array( + 'url' => (string) $args['main_image_url'], + 'mime_type' => isset( $args['main_image_mime_type'] ) && '' !== (string) $args['main_image_mime_type'] + ? (string) $args['main_image_mime_type'] + : 'image/jpeg', + ); + } elseif ( '' !== $featured_image_url ) { + $payload['main_image'] = array( + 'url' => $featured_image_url, + 'mime_type' => $featured_image_mime ? $featured_image_mime : 'image/jpeg', + ); + } + + if ( isset( $args['languages'] ) && is_array( $args['languages'] ) ) { + $languages = array_values( + array_filter( + array_map( 'strtolower', array_map( 'strval', $args['languages'] ) ), + static function ( $code ) { + return '' !== $code; + } + ) + ); + if ( ! empty( $languages ) ) { + $payload['languages'] = $languages; + } + } + if ( isset( $args['countries'] ) && is_array( $args['countries'] ) ) { + $countries = array_values( + array_filter( + array_map( 'strtoupper', array_map( 'strval', $args['countries'] ) ), + static function ( $code ) { + return 2 === strlen( $code ); + } + ) + ); + if ( ! empty( $countries ) ) { + $payload['countries'] = $countries; + } + } + + return $payload; + } + + /** + * Build the Blaze review deep-link with the encoded prefill payload. + * + * @param int $post_id The target post ID. + * @param array $prefill The prefill payload to encode. + * @return string + */ + private static function build_prefill_url( int $post_id, array $prefill ): string { + $base = ''; + if ( class_exists( '\Automattic\Jetpack\Blaze' ) && method_exists( '\Automattic\Jetpack\Blaze', 'get_campaign_management_url' ) ) { + $url_data = \Automattic\Jetpack\Blaze::get_campaign_management_url( $post_id ); + if ( is_array( $url_data ) && isset( $url_data['link'] ) ) { + $base = (string) $url_data['link']; + } + } + if ( '' === $base ) { + $base = admin_url( 'tools.php?page=advertising' ); + } + + $encoded = self::base64url_encode( (string) wp_json_encode( $prefill, JSON_UNESCAPED_SLASHES ) ); + $param = 'blaze_prefill=' . rawurlencode( $encoded ); + + $hash_pos = strpos( $base, '#' ); + if ( false === $hash_pos ) { + $separator = ( false !== strpos( $base, '?' ) ) ? '&' : '?'; + return $base . $separator . $param; + } + + $pre_hash = substr( $base, 0, $hash_pos ); + $hash = substr( $base, $hash_pos ); + $separator = ( false !== strpos( $pre_hash, '?' ) ) ? '&' : '?'; + + return $pre_hash . $separator . $param . $hash; + } + + /** + * URL-safe base64 encode (RFC 4648 section 5). + * + * @param string $input Bytes to encode. + * @return string + */ + private static function base64url_encode( string $input ): string { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encoding a structured prefill payload, not obfuscation. + return rtrim( strtr( base64_encode( $input ), '+/', '-_' ), '=' ); + } + + /** + * Currency code for the prefill payload. + * + * @return string ISO 4217 currency code. + */ + private static function get_site_currency(): string { + return 'USD'; + } +} diff --git a/projects/packages/blaze/tests/php/Campaign_Preparer_Test.php b/projects/packages/blaze/tests/php/Campaign_Preparer_Test.php new file mode 100644 index 000000000000..9cf6db8afa92 --- /dev/null +++ b/projects/packages/blaze/tests/php/Campaign_Preparer_Test.php @@ -0,0 +1,152 @@ + 'Test product page', + 'post_excerpt' => 'A short summary about the product.', + 'post_content' => 'Long-form content that would otherwise be the fallback snippet source.', + 'post_status' => 'publish', + 'post_type' => 'post', + ), + $overrides + ) + ); + + return array( + 'post_id' => (int) $post_id, + 'target_urn' => sprintf( 'urn:wpcom:post:%d:%d', self::TEST_SITE_ID, (int) $post_id ), + ); + } + + /** + * The preparer returns reusable structured data and leaves MCP-specific + * human-readable messaging to the ability adapter. + */ + public function test_prepare_returns_structured_proposal_without_mcp_message() { + $ctx = $this->make_test_post(); + + $result = Campaign_Preparer::prepare( + array( + 'target_urn' => $ctx['target_urn'], + 'budget_total' => 75, + 'duration_days' => 10, + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( 'pending_merchant_review', $result['status'] ); + $this->assertArrayHasKey( 'prefill_url', $result ); + $this->assertArrayHasKey( 'prefill', $result ); + $this->assertArrayNotHasKey( 'message', $result ); + $this->assertStringContainsString( 'blaze_prefill=', $result['prefill_url'] ); + + $prefill = $result['prefill']; + $this->assertSame( $ctx['target_urn'], $prefill['target_urn'] ); + $this->assertSame( 'Test product page', $prefill['site_name'] ); + $this->assertSame( 'A short summary about the product.', $prefill['text_snippet'] ); + $this->assertSame( 75.0, $prefill['budget']['amount'] ); + $this->assertSame( 10, $prefill['duration_days'] ); + } + + /** + * Caller overrides and targeting hints are normalized by the preparer. + */ + public function test_prepare_applies_overrides_and_targeting_hints() { + $ctx = $this->make_test_post( + array( + 'post_type' => 'product', + ) + ); + + $result = Campaign_Preparer::prepare( + array( + 'target_urn' => $ctx['target_urn'], + 'budget_total' => 150, + 'duration_days' => 7, + 'site_name' => 'Custom heading', + 'text_snippet' => 'Custom ad copy.', + 'goal' => 'Drive sales in the UK.', + 'revision_instruction' => 'Make it more direct.', + 'main_image_url' => 'https://example.com/custom.jpg', + 'main_image_mime_type' => 'image/jpeg', + 'languages' => array( 'EN', '', 'es' ), + 'countries' => array( 'gb', 'usa', 'FR' ), + 'is_evergreen' => false, + ) + ); + + $this->assertIsArray( $result ); + $prefill = $result['prefill']; + $this->assertSame( 'Custom heading', $prefill['site_name'] ); + $this->assertSame( 'Custom ad copy.', $prefill['text_snippet'] ); + $this->assertSame( 'Shop Now', $prefill['cta_text'] ); + $this->assertSame( 'Drive sales in the UK.', $prefill['goal'] ); + $this->assertSame( 'Make it more direct.', $prefill['revision_instruction'] ); + $this->assertSame( 'https://example.com/custom.jpg', $prefill['main_image']['url'] ); + $this->assertSame( array( 'en', 'es' ), $prefill['languages'] ); + $this->assertSame( array( 'GB', 'FR' ), $prefill['countries'] ); + $this->assertFalse( $prefill['is_evergreen'] ); + $this->assertSame( 'VIEWS', $prefill['objective'] ); + } + + /** + * Invalid targets remain hard stops owned by the preparation layer. + */ + public function test_prepare_returns_error_for_invalid_target_urn() { + $result = Campaign_Preparer::prepare( + array( + 'target_urn' => 'not-a-urn', + ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'blaze_invalid_target_urn', $result->get_error_code() ); + } +} diff --git a/projects/packages/blaze/tests/php/abilities/Blaze_Abilities_Test.php b/projects/packages/blaze/tests/php/abilities/Blaze_Abilities_Test.php index 6bdda6af152a..a57791b5962f 100644 --- a/projects/packages/blaze/tests/php/abilities/Blaze_Abilities_Test.php +++ b/projects/packages/blaze/tests/php/abilities/Blaze_Abilities_Test.php @@ -372,6 +372,7 @@ public function test_prepare_campaign_schema_is_minimal_and_renamed() { $abilities = Blaze_Abilities::get_abilities(); $this->assertArrayHasKey( 'blaze-ads/prepare-campaign', $abilities ); + $this->assertStringContainsString( 'Device targeting and interest/topic targeting are not public MCP inputs in v1', $abilities[ Blaze_Abilities::ABILITY_PREPARE_CAMPAIGN ]['description'] ); $schema = $abilities[ Blaze_Abilities::ABILITY_PREPARE_CAMPAIGN ]['input_schema']; $properties = $schema['properties']; @@ -389,6 +390,11 @@ public function test_prepare_campaign_schema_is_minimal_and_renamed() { $this->assertArrayHasKey( 'languages', $properties ); $this->assertArrayHasKey( 'countries', $properties ); $this->assertArrayNotHasKey( 'objective', $properties ); + $this->assertArrayNotHasKey( 'devices', $properties ); + $this->assertArrayNotHasKey( 'interests', $properties ); + $this->assertArrayNotHasKey( 'page_topics', $properties ); + $this->assertStringContainsString( 'ISO 639-1', $properties['languages']['description'] ); + $this->assertStringContainsString( 'ISO 3166-1 alpha-2', $properties['countries']['description'] ); } // --- prepare_campaign: prefill payload + URL ---