diff --git a/includes/Modules/Analytics_4/Email_Reporting/Report_Data_Builder.php b/includes/Modules/Analytics_4/Email_Reporting/Report_Data_Builder.php index 8aa9b3175b3..34c8e5efe0e 100644 --- a/includes/Modules/Analytics_4/Email_Reporting/Report_Data_Builder.php +++ b/includes/Modules/Analytics_4/Email_Reporting/Report_Data_Builder.php @@ -84,9 +84,14 @@ public function __construct( ?Email_Report_Payload_Processor $report_processor = * @return array Section payloads. */ public function build_sections_from_module_payload( $module_payload ) { - $sections = array(); + $sections = array(); + $top_channels_by_event = $this->extract_top_channels_by_event( $module_payload ); foreach ( $module_payload as $section_key => $section_data ) { + if ( $this->is_conversion_event_top_channel_key( $section_key ) ) { + continue; + } + list( $reports ) = $this->normalize_section_input( $section_data ); foreach ( $reports as $report ) { @@ -109,6 +114,8 @@ public function build_sections_from_module_payload( $module_payload ) { $payload['title'] = ''; } + $payload = $this->maybe_merge_conversion_event_top_channel( $payload, $section_key, $top_channels_by_event ); + $sections[] = $payload; } } @@ -265,4 +272,134 @@ function ( $dimension_value ) { return array( $formatted_values, $labels ); } + + /** + * Extracts top traffic channels from conversion sidecar payloads. + * + * @since n.e.x.t + * + * @param array $module_payload Analytics module payload. + * @return array Event slug => top channel. + */ + private function extract_top_channels_by_event( $module_payload ) { + $top_channels_by_event = array(); + + if ( ! is_array( $module_payload ) ) { + return $top_channels_by_event; + } + + foreach ( $module_payload as $section_key => $section_data ) { + if ( ! $this->is_conversion_event_top_channel_key( $section_key ) ) { + continue; + } + + $event_slug = $this->get_conversion_event_slug_from_top_channel_key( $section_key ); + if ( '' === $event_slug ) { + continue; + } + + $top_channel = $this->extract_top_channel_from_section_data( $section_data ); + if ( '' === $top_channel ) { + continue; + } + + $top_channels_by_event[ $event_slug ] = $top_channel; + } + + return $top_channels_by_event; + } + + /** + * Merges sidecar top-channel data into a conversion-event payload. + * + * @since n.e.x.t + * + * @param array $payload Section payload. + * @param string $section_key Section key. + * @param array $top_channels_by_event Event slug => top channel map. + * @return array Section payload. + */ + private function maybe_merge_conversion_event_top_channel( $payload, $section_key, $top_channels_by_event ) { + if ( 0 !== strpos( $section_key, 'conversion_event_' ) ) { + return $payload; + } + + $event_slug = substr( $section_key, strlen( 'conversion_event_' ) ); + if ( '' === $event_slug ) { + return $payload; + } + + if ( empty( $top_channels_by_event[ $event_slug ] ) ) { + $payload['dimensions'] = array(); + $payload['dimension_values'] = array(); + + return $payload; + } + + $payload['dimensions'] = array( 'sessionDefaultChannelGroup' ); + $payload['dimension_values'] = array( $top_channels_by_event[ $event_slug ] ); + + return $payload; + } + + /** + * Extracts the top channel value from sidecar report data. + * + * @since n.e.x.t + * + * @param mixed $section_data Section data for a top-channel request. + * @return string Top channel value, or empty string when unavailable. + */ + private function extract_top_channel_from_section_data( $section_data ) { + list( $reports ) = $this->normalize_section_input( $section_data ); + if ( empty( $reports ) ) { + return ''; + } + + foreach ( $reports as $report ) { + $processed_report = $this->report_processor->process_single_report( $report ); + $rows = $processed_report['rows'] ?? array(); + + if ( empty( $rows ) || ! is_array( $rows ) ) { + continue; + } + + foreach ( $rows as $row ) { + $top_channel = $row['dimensions']['sessionDefaultChannelGroup'] ?? ''; + if ( '' !== $top_channel ) { + return $top_channel; + } + } + } + + return ''; + } + + /** + * Determines whether section key is a conversion top-channel sidecar key. + * + * @since n.e.x.t + * + * @param string $section_key Section key. + * @return bool True when key is a conversion top-channel key. + */ + private function is_conversion_event_top_channel_key( $section_key ) { + return 0 === strpos( $section_key, 'conversion_event_top_channel_' ); + } + + /** + * Gets conversion event slug from a top-channel sidecar key. + * + * @since n.e.x.t + * + * @param string $section_key Section key. + * @return string Conversion event slug. + */ + private function get_conversion_event_slug_from_top_channel_key( $section_key ) { + if ( ! $this->is_conversion_event_top_channel_key( $section_key ) ) { + return ''; + } + + return substr( $section_key, strlen( 'conversion_event_top_channel_' ) ); + } } diff --git a/includes/Modules/Analytics_4/Email_Reporting/Report_Options.php b/includes/Modules/Analytics_4/Email_Reporting/Report_Options.php index 534ec4ced5a..c04254e1212 100644 --- a/includes/Modules/Analytics_4/Email_Reporting/Report_Options.php +++ b/includes/Modules/Analytics_4/Email_Reporting/Report_Options.php @@ -213,6 +213,30 @@ public function get_total_conversion_events_options() { * @return array Report request options array. */ public function get_conversion_event_options( string $event_name ) { + return $this->with_current_range( + array( + 'metrics' => array( + array( 'name' => 'eventCount' ), + ), + 'dimensionFilters' => array( + 'eventName' => array( + 'value' => $event_name, + ), + ), + 'keepEmptyRows' => true, + ) + ); + } + + /** + * Gets report options for top conversion channel by event. + * + * @since n.e.x.t + * + * @param string $event_name Conversion event name. + * @return array Report request options array. + */ + public function get_conversion_event_top_channel_options( string $event_name ) { return $this->with_current_range( array( 'metrics' => array( diff --git a/includes/Modules/Analytics_4/Email_Reporting/Report_Request_Assembler.php b/includes/Modules/Analytics_4/Email_Reporting/Report_Request_Assembler.php index 57a4c002c6e..c6ee2289a75 100644 --- a/includes/Modules/Analytics_4/Email_Reporting/Report_Request_Assembler.php +++ b/includes/Modules/Analytics_4/Email_Reporting/Report_Request_Assembler.php @@ -69,6 +69,7 @@ public function build_requests( array $custom_titles = array() ) { } $requests[ $request_key ] = $this->report_options->get_conversion_event_options( $event_name ); + $requests[ sprintf( 'conversion_event_top_channel_%s', $event_name_slug ) ] = $this->report_options->get_conversion_event_top_channel_options( $event_name ); } } diff --git a/tests/phpunit/integration/Core/Email_Reporting/Email_Report_Section_BuilderTest.php b/tests/phpunit/integration/Core/Email_Reporting/Email_Report_Section_BuilderTest.php index 19a8195d9fc..44330680be2 100644 --- a/tests/phpunit/integration/Core/Email_Reporting/Email_Report_Section_BuilderTest.php +++ b/tests/phpunit/integration/Core/Email_Reporting/Email_Report_Section_BuilderTest.php @@ -14,6 +14,9 @@ use Google\Site_Kit\Core\Email_Reporting\Email_Log; use Google\Site_Kit\Core\Email_Reporting\Email_Report_Section_Builder; use Google\Site_Kit\Core\Email_Reporting\Email_Report_Data_Section_Part; +use Google\Site_Kit\Core\Email_Reporting\Sections_Map; +use Google\Site_Kit\Core\Golinks\Dashboard_Golink_Handler; +use Google\Site_Kit\Core\Golinks\Golinks; use Google\Site_Kit\Tests\TestCase; class Email_Report_Section_BuilderTest extends TestCase { @@ -139,4 +142,180 @@ public function test_build_sections__returns_wp_error_for_search_console_error_p $this->assertWPError( $sections, 'Search Console errors should be propagated as WP_Error.' ); $this->assertSame( 'email_report_search_console_missing_result', $sections->get_error_code(), 'Expected original Search Console error code to be preserved.' ); } + + public function test_build_sections__uses_total_conversion_count_and_top_channel_sidecar_data() { + $context = new Context( GOOGLESITEKIT_PLUGIN_MAIN_FILE ); + $builder = new Email_Report_Section_Builder( $context ); + + $payloads = array( + array( + 'total_conversion_events' => $this->get_conversion_event_total_report( '90' ), + 'conversion_event_add_to_cart' => $this->get_conversion_event_total_report( '50' ), + 'conversion_event_begin_checkout' => $this->get_conversion_event_total_report( '30' ), + 'conversion_event_purchase' => $this->get_conversion_event_total_report( '10' ), + 'conversion_event_top_channel_add_to_cart' => $this->get_conversion_event_top_channel_report( 'Organic Search', '5' ), + 'conversion_event_top_channel_begin_checkout' => $this->get_conversion_event_top_channel_report( 'Direct', '7' ), + 'conversion_event_top_channel_purchase' => $this->get_conversion_event_top_channel_report( 'Paid Search', '999' ), + ), + ); + + $sections = $builder->build_sections( 'analytics-4', $payloads, 'en_US' ); + + $this->assertIsArray( $sections, 'Expected analytics sections array.' ); + $this->assertCount( 4, $sections, 'Expected total plus three conversion event sections; top-channel sidecar reports should not render as standalone sections.' ); + + $section_by_key = $this->map_sections_by_key( $sections ); + + $this->assertArrayNotHasKey( 'conversion_event_top_channel_add_to_cart', $section_by_key, 'Top-channel sidecar payload should not become a standalone section.' ); + $this->assertArrayHasKey( 'conversion_event_add_to_cart', $section_by_key, 'Expected conversion event section to be present.' ); + + $add_to_cart_section = $section_by_key['conversion_event_add_to_cart']; + $this->assertSame( array( '50' ), $add_to_cart_section->get_values(), 'Conversion event value should come from total event count.' ); + $this->assertSame( array( 'sessionDefaultChannelGroup' ), $add_to_cart_section->get_dimensions(), 'Conversion event should expose top-channel dimension only when sidecar data exists.' ); + $this->assertSame( array( 'Organic Search' ), $add_to_cart_section->get_dimension_values(), 'Conversion event top channel should be merged from sidecar payload.' ); + + $sections_payload = $this->to_sections_payload( $sections ); + $golinks = new Golinks( $context ); + $golinks->register_handler( 'dashboard', new Dashboard_Golink_Handler() ); + $sections_map = new Sections_Map( $context, $sections_payload, $golinks ); + $section_map = $sections_map->get_sections(); + + $this->assertArrayHasKey( 'is_my_site_helping_my_business_grow', $section_map, 'Expected conversions section in section map.' ); + $this->assertSame( + array( + 'total_conversion_events', + 'conversion_event_add_to_cart', + 'conversion_event_begin_checkout', + ), + array_keys( $section_map['is_my_site_helping_my_business_grow']['section_parts'] ), + 'Conversion rows should be ranked by total event counts, not top-channel sidecar counts.' + ); + } + + public function test_build_sections__keeps_conversion_event_row_when_top_channel_sidecar_is_missing() { + $context = new Context( GOOGLESITEKIT_PLUGIN_MAIN_FILE ); + $builder = new Email_Report_Section_Builder( $context ); + + $payloads = array( + array( + 'total_conversion_events' => $this->get_conversion_event_total_report( '12' ), + 'conversion_event_add_to_cart' => $this->get_conversion_event_total_report( '4' ), + ), + ); + + $sections = $builder->build_sections( 'analytics-4', $payloads, 'en_US' ); + + $this->assertIsArray( $sections, 'Expected analytics sections array.' ); + $this->assertCount( 2, $sections, 'Expected total plus conversion event section.' ); + + $section_by_key = $this->map_sections_by_key( $sections ); + + $this->assertArrayHasKey( 'conversion_event_add_to_cart', $section_by_key, 'Conversion event should be present even when top-channel sidecar data is missing.' ); + + $add_to_cart_section = $section_by_key['conversion_event_add_to_cart']; + $this->assertSame( array( '4' ), $add_to_cart_section->get_values(), 'Conversion event value should still come from total event count.' ); + $this->assertSame( array(), $add_to_cart_section->get_dimensions(), 'Conversion event should not expose a top-channel dimension when sidecar data is missing.' ); + $this->assertSame( array(), $add_to_cart_section->get_dimension_values(), 'Conversion event should not expose a top-channel value when sidecar data is missing.' ); + } + + /** + * Converts built section parts into section payload used by sections map. + * + * @param Email_Report_Data_Section_Part[] $sections Built section parts. + * @return array + */ + private function to_sections_payload( array $sections ) { + $payload = array(); + + foreach ( $sections as $section ) { + $dimensions = $section->get_dimensions(); + $dimension_values = $section->get_dimension_values(); + $first_dimension = $dimensions[0] ?? ''; + $first_value = $dimension_values[0] ?? ''; + $first_label = is_array( $first_value ) ? ( $first_value['label'] ?? '' ) : $first_value; + + $payload[ $section->get_section_key() ] = array( + 'value' => $section->get_values()[0] ?? '', + 'label' => $section->get_labels()[0] ?? '', + 'event_name' => $section->get_event_names()[0] ?? '', + 'dimension' => $first_dimension, + 'dimension_value' => $first_label, + 'change_context' => 'Compared to previous 7 days', + ); + } + + return $payload; + } + + /** + * Builds a minimal GA4 conversion event totals report fixture. + * + * @param string $value Event count value. + * @return array + */ + private function get_conversion_event_total_report( $value ) { + return array( + 'metricHeaders' => array( + array( + 'name' => 'eventCount', + 'type' => 'TYPE_INTEGER', + ), + ), + 'totals' => array( + array( + 'metricValues' => array( + array( 'value' => (string) $value ), + ), + ), + ), + 'rowCount' => 1, + ); + } + + /** + * Builds a minimal GA4 conversion event top-channel report fixture. + * + * @param string $channel Top channel name. + * @param string $value Event count value. + * @return array + */ + private function get_conversion_event_top_channel_report( $channel, $value ) { + return array( + 'dimensionHeaders' => array( + array( 'name' => 'sessionDefaultChannelGroup' ), + ), + 'metricHeaders' => array( + array( + 'name' => 'eventCount', + 'type' => 'TYPE_INTEGER', + ), + ), + 'rows' => array( + array( + 'dimensionValues' => array( + array( 'value' => (string) $channel ), + ), + 'metricValues' => array( + array( 'value' => (string) $value ), + ), + ), + ), + ); + } + + /** + * Maps section parts by section key for easier assertions. + * + * @param Email_Report_Data_Section_Part[] $sections Built section parts. + * @return Email_Report_Data_Section_Part[] + */ + private function map_sections_by_key( array $sections ) { + $section_by_key = array(); + + foreach ( $sections as $section ) { + $section_by_key[ $section->get_section_key() ] = $section; + } + + return $section_by_key; + } } diff --git a/tests/phpunit/integration/Core/Email_Reporting/Email_Reporting_Data_RequestsTest.php b/tests/phpunit/integration/Core/Email_Reporting/Email_Reporting_Data_RequestsTest.php index 8cd10d88a44..40b7f5dfe85 100644 --- a/tests/phpunit/integration/Core/Email_Reporting/Email_Reporting_Data_RequestsTest.php +++ b/tests/phpunit/integration/Core/Email_Reporting/Email_Reporting_Data_RequestsTest.php @@ -137,6 +137,8 @@ public function test_admin_user_receives_payloads() { $this->assertArrayHasKey( 'total_conversion_events', $payload[ Analytics_4::MODULE_SLUG ], 'Conversion events payload should be included.' ); $this->assertArrayHasKey( 'conversion_event_add_to_cart', $payload[ Analytics_4::MODULE_SLUG ], 'Add to cart conversion event payload should be included.' ); $this->assertArrayHasKey( 'conversion_event_purchase', $payload[ Analytics_4::MODULE_SLUG ], 'Purchase conversion event payload should be included.' ); + $this->assertArrayHasKey( 'conversion_event_top_channel_add_to_cart', $payload[ Analytics_4::MODULE_SLUG ], 'Add to cart top channel payload should be included.' ); + $this->assertArrayHasKey( 'conversion_event_top_channel_purchase', $payload[ Analytics_4::MODULE_SLUG ], 'Purchase top channel payload should be included.' ); $this->assertArrayHasKey( 'total_visitors', $payload[ Analytics_4::MODULE_SLUG ], 'Total visitors payload should be included.' ); $this->assertArrayHasKey( 'traffic_channels', $payload[ Analytics_4::MODULE_SLUG ], 'Traffic channels payload should be included.' ); $this->assertArrayHasKey( 'popular_content', $payload[ Analytics_4::MODULE_SLUG ], 'Popular content payload should be included.' ); @@ -265,6 +267,8 @@ public function test_view_only_user_with_shared_module_gets_shared_payload_only( $this->assertArrayHasKey( 'total_conversion_events', $payload[ Analytics_4::MODULE_SLUG ], 'Shared viewer should see conversion events.' ); $this->assertArrayHasKey( 'conversion_event_add_to_cart', $payload[ Analytics_4::MODULE_SLUG ], 'Shared viewer should see add to cart conversion event data.' ); $this->assertArrayHasKey( 'conversion_event_purchase', $payload[ Analytics_4::MODULE_SLUG ], 'Shared viewer should see purchase conversion event data.' ); + $this->assertArrayHasKey( 'conversion_event_top_channel_add_to_cart', $payload[ Analytics_4::MODULE_SLUG ], 'Shared viewer should see add to cart top channel data.' ); + $this->assertArrayHasKey( 'conversion_event_top_channel_purchase', $payload[ Analytics_4::MODULE_SLUG ], 'Shared viewer should see purchase top channel data.' ); $this->assertArrayNotHasKey( 'new_visitors', $payload[ Analytics_4::MODULE_SLUG ], 'Audience segmentation data should be absent.' ); $this->assertArrayNotHasKey( 'returning_visitors', $payload[ Analytics_4::MODULE_SLUG ], 'Audience segmentation data should be absent.' ); $this->assertArrayNotHasKey( 'top_authors', $payload[ Analytics_4::MODULE_SLUG ], 'Custom dimension authors data should be absent.' ); diff --git a/tests/phpunit/integration/Modules/Analytics_4/Email_Reporting/Report_OptionsTest.php b/tests/phpunit/integration/Modules/Analytics_4/Email_Reporting/Report_OptionsTest.php index 4534744719d..15a5820a647 100644 --- a/tests/phpunit/integration/Modules/Analytics_4/Email_Reporting/Report_OptionsTest.php +++ b/tests/phpunit/integration/Modules/Analytics_4/Email_Reporting/Report_OptionsTest.php @@ -109,15 +109,35 @@ public function test_conversion_event_options__builds_expected_request() { $options['metrics'], 'Conversion event report should request eventCount.' ); + $this->assertSame( + array( 'value' => 'begin_checkout' ), + $options['dimensionFilters']['eventName'], + 'Conversion event report should filter by the requested event name.' + ); + $this->assertArrayNotHasKey( 'dimensions', $options, 'Conversion event report should not group by channel dimensions.' ); + $this->assertArrayNotHasKey( 'orderby', $options, 'Conversion event report should not order by channel metric.' ); + $this->assertArrayNotHasKey( 'limit', $options, 'Conversion event report should not limit rows by top channel.' ); + $this->assertTrue( $options['keepEmptyRows'], 'Conversion event report should include empty rows.' ); + } + + public function test_conversion_event_top_channel_options__builds_expected_request() { + $builder = $this->create_builder(); + $options = $builder->get_conversion_event_top_channel_options( 'begin_checkout' ); + + $this->assertSame( + array( array( 'name' => 'eventCount' ) ), + $options['metrics'], + 'Top channel report should request eventCount.' + ); $this->assertSame( array( array( 'name' => 'sessionDefaultChannelGroup' ) ), $options['dimensions'], - 'Conversion event report should group by sessionDefaultChannelGroup.' + 'Top channel report should group by sessionDefaultChannelGroup.' ); $this->assertSame( array( 'value' => 'begin_checkout' ), $options['dimensionFilters']['eventName'], - 'Conversion event report should filter by the requested event name.' + 'Top channel report should filter by the requested event name.' ); $this->assertSame( array( @@ -127,10 +147,10 @@ public function test_conversion_event_options__builds_expected_request() { ), ), $options['orderby'], - 'Conversion event report should order rows by event count descending.' + 'Top channel report should order rows by event count descending.' ); - $this->assertSame( 1, $options['limit'], 'Conversion event report should limit to one top row.' ); - $this->assertTrue( $options['keepEmptyRows'], 'Conversion event report should include empty rows.' ); + $this->assertSame( 1, $options['limit'], 'Top channel report should limit to one top row.' ); + $this->assertTrue( $options['keepEmptyRows'], 'Top channel report should include empty rows.' ); } public function test_top_categories_uses_custom_dimension() {