diff --git a/includes/Modules/Analytics.php b/includes/Modules/Analytics.php index b9317bd5763..ea0ed17aa45 100644 --- a/includes/Modules/Analytics.php +++ b/includes/Modules/Analytics.php @@ -957,16 +957,7 @@ protected function create_analytics_site_data_request( array $args = array() ) { $dimension_filter_clauses = array(); - $hostnames = array_values( - array_unique( - array_map( - function ( $site_url ) { - return URL::parse( $site_url, PHP_URL_HOST ); - }, - $this->permute_site_url( $this->context->get_reference_site_url() ) - ) - ) - ); + $hostnames = $this->permute_site_hosts( URL::parse( $this->context->get_reference_site_url(), PHP_URL_HOST ) ); $dimension_filter = new Google_Service_AnalyticsReporting_DimensionFilter(); $dimension_filter->setDimensionName( 'ga:hostname' ); diff --git a/includes/Modules/Analytics_4.php b/includes/Modules/Analytics_4.php index a0d0cc0823b..9018a927ca7 100644 --- a/includes/Modules/Analytics_4.php +++ b/includes/Modules/Analytics_4.php @@ -37,18 +37,31 @@ use Google\Site_Kit\Core\Util\Method_Proxy_Trait; use Google\Site_Kit\Core\Util\Sort; use Google\Site_Kit\Core\Util\URL; +use Google\Site_Kit\Core\Validation\Exception\Invalid_Report_Dimensions_Exception; +use Google\Site_Kit\Core\Validation\Exception\Invalid_Report_Metrics_Exception; use Google\Site_Kit\Modules\Analytics\Settings as Analytics_Settings; use Google\Site_Kit\Modules\Analytics_4\Settings; use Google\Site_Kit\Modules\Analytics_4\Tag_Guard; use Google\Site_Kit\Modules\Analytics_4\Web_Tag; use Google\Site_Kit_Dependencies\Google\Model as Google_Model; +use Google\Site_Kit_Dependencies\Google\Service\AnalyticsData as Google_Service_AnalyticsData; +use Google\Site_Kit_Dependencies\Google\Service\AnalyticsData\DateRange as Google_Service_AnalyticsData_DateRange; +use Google\Site_Kit_Dependencies\Google\Service\AnalyticsData\Dimension as Google_Service_AnalyticsData_Dimension; +use Google\Site_Kit_Dependencies\Google\Service\AnalyticsData\Filter as Google_Service_AnalyticsData_Filter; +use Google\Site_Kit_Dependencies\Google\Service\AnalyticsData\FilterExpression as Google_Service_AnalyticsData_FilterExpression; +use Google\Site_Kit_Dependencies\Google\Service\AnalyticsData\FilterExpressionList as Google_Service_AnalyticsData_FilterExpressionList; +use Google\Site_Kit_Dependencies\Google\Service\AnalyticsData\InListFilter as Google_Service_AnalyticsData_InListFilter; +use Google\Site_Kit_Dependencies\Google\Service\AnalyticsData\MetricOrderBy as Google_Service_AnalyticsData_MetricOrderBy; +use Google\Site_Kit_Dependencies\Google\Service\AnalyticsData\OrderBy as Google_Service_AnalyticsData_OrderBy; +use Google\Site_Kit_Dependencies\Google\Service\AnalyticsData\RunReportRequest as Google_Service_AnalyticsData_RunReportRequest; +use Google\Site_Kit_Dependencies\Google\Service\AnalyticsData\StringFilter as Google_Service_AnalyticsData_StringFilter; +use Google\Site_Kit_Dependencies\Google\Service\AnalyticsData\Metric as Google_Service_AnalyticsData_Metric; use Google\Site_Kit_Dependencies\Google\Service\GoogleAnalyticsAdmin as Google_Service_GoogleAnalyticsAdmin; use Google\Site_Kit_Dependencies\Google\Service\GoogleAnalyticsAdmin\GoogleAnalyticsAdminV1betaDataStream; use Google\Site_Kit_Dependencies\Google\Service\GoogleAnalyticsAdmin\GoogleAnalyticsAdminV1betaDataStreamWebStreamData; use Google\Site_Kit_Dependencies\Google\Service\GoogleAnalyticsAdmin\GoogleAnalyticsAdminV1betaListDataStreamsResponse; use Google\Site_Kit_Dependencies\Google\Service\GoogleAnalyticsAdmin\GoogleAnalyticsAdminV1betaProperty as Google_Service_GoogleAnalyticsAdmin_GoogleAnalyticsAdminV1betaProperty; use Google\Site_Kit_Dependencies\Google\Service\TagManager as Google_Service_TagManager; -use Google\Site_Kit_Dependencies\Google\Service\TagManager\Container as Google_Service_TagManager_Container; use Google\Site_Kit_Dependencies\Psr\Http\Message\RequestInterface; use stdClass; use WP_Error; @@ -216,6 +229,10 @@ protected function get_datapoint_definitions() { ), 'GET:properties' => array( 'service' => 'analyticsadmin' ), 'GET:property' => array( 'service' => 'analyticsadmin' ), + 'GET:report' => array( + 'service' => 'analyticsdata', + 'shareable' => Feature_Flags::enabled( 'dashboardSharing' ), + ), 'GET:webdatastreams' => array( 'service' => 'analyticsadmin' ), 'GET:webdatastreams-batch' => array( 'service' => 'analyticsadmin' ), ); @@ -397,6 +414,8 @@ protected function create_data_request( Data_Request $data ) { } return $this->get_service( 'analyticsadmin' )->properties->get( self::normalize_property_id( $data['propertyID'] ) ); + case 'GET:report': + return $this->create_report_request( $data ); case 'GET:webdatastreams': if ( ! isset( $data['propertyID'] ) ) { return new WP_Error( @@ -572,6 +591,18 @@ protected function setup_info() { ); } + + /** + * Gets the configured Analytics Data service object instance. + * + * @since n.e.x.t + * + * @return Google_Service_AnalyticsData The Analytics Data API service. + */ + protected function get_analyticsdata_service() { + return $this->get_service( 'analyticsdata' ); + } + /** * Sets up the Google services the module should use. * @@ -587,6 +618,7 @@ protected function setup_info() { protected function setup_services( Google_Site_Kit_Client $client ) { return array( 'analyticsadmin' => new Google_Service_GoogleAnalyticsAdmin( $client ), + 'analyticsdata' => new Google_Service_AnalyticsData( $client ), 'tagmanager' => new Google_Service_TagManager( $client ), ); } @@ -831,4 +863,455 @@ public function check_service_entity_access() { return true; } + /** + * Creates and executes a new Analytics 4 report request. + * + * @since n.e.x.t + * + * @param Data_Request $data Data request object. + * @return RequestInterface|WP_Error Request object on success, or WP_Error on failure. + */ + protected function create_report_request( Data_Request $data ) { + $request_args = array(); + + $option = $this->get_settings()->get(); + + if ( empty( $data['metrics'] ) ) { + return new WP_Error( + 'missing_required_param', + /* translators: %s: Missing parameter name */ + sprintf( __( 'Request parameter is empty: %s.', 'google-site-kit' ), 'metrics' ), + array( 'status' => 400 ) + ); + } + + if ( empty( $option['propertyID'] ) ) { + return new WP_Error( + 'missing_required_setting', + __( 'No connected Google Analytics 4 property ID.', 'google-site-kit' ), + array( 'status' => 500 ) + ); + } + + if ( ! empty( $data['url'] ) ) { + $request_args['page'] = $data['url']; + } + + if ( ! empty( $data['limit'] ) ) { + $request_args['row_limit'] = $data['limit']; + } + + $dimensions = $data['dimensions']; + if ( ! empty( $dimensions ) && ( is_string( $dimensions ) || is_array( $dimensions ) ) ) { + if ( is_string( $dimensions ) ) { + $dimensions = explode( ',', $dimensions ); + } elseif ( is_array( $dimensions ) && ! wp_is_numeric_array( $dimensions ) ) { // If single object is passed. + $dimensions = array( $dimensions ); + } + + $dimensions = array_filter( + array_map( + function ( $dimension_def ) { + $dimension = new Google_Service_AnalyticsData_Dimension(); + + if ( is_string( $dimension_def ) ) { + $dimension->setName( $dimension_def ); + } elseif ( is_array( $dimension_def ) && ! empty( $dimension_def['name'] ) ) { + $dimension->setName( $dimension_def['name'] ); + } else { + return null; + } + + return $dimension; + }, + array_filter( $dimensions ) + ) + ); + + if ( ! empty( $dimensions ) ) { + try { + $this->validate_report_dimensions( $dimensions ); + } catch ( Invalid_Report_Dimensions_Exception $exception ) { + return new WP_Error( + 'invalid_analytics_4_report_dimensions', + $exception->getMessage() + ); + } + + $request_args['dimensions'] = $dimensions; + } + } + + $dimension_filters = $data['dimensionFilters']; + $dimension_filter_expressions = array(); + if ( ! empty( $dimension_filters ) && is_array( $dimension_filters ) ) { + foreach ( $dimension_filters as $dimension_name => $dimension_value ) { + $dimension_filter = new Google_Service_AnalyticsData_Filter(); + $dimension_filter->setFieldName( $dimension_name ); + if ( is_array( $dimension_value ) ) { + $dimension_in_list_filter = new Google_Service_AnalyticsData_InListFilter(); + $dimension_in_list_filter->setValues( $dimension_value ); + $dimension_filter->setInListFilter( $dimension_in_list_filter ); + } else { + $dimension_string_filter = new Google_Service_AnalyticsData_StringFilter(); + $dimension_string_filter->setMatchType( 'EXACT' ); + $dimension_string_filter->setValue( $dimension_value ); + $dimension_filter->setStringFilter( $dimension_string_filter ); + } + $dimension_filter_expression = new Google_Service_AnalyticsData_FilterExpression(); + $dimension_filter_expression->setFilter( $dimension_filter ); + $dimension_filter_expressions[] = $dimension_filter_expression; + } + + if ( ! empty( $dimension_filter_expressions ) ) { + $request_args['dimension_filters'] = $dimension_filter_expressions; + } + } + + $request = $this->create_analytics_site_data_request( $option['propertyID'], $request_args ); + + if ( is_wp_error( $request ) ) { + return $request; + } + + $date_ranges = array(); + $start_date = $data['startDate']; + $end_date = $data['endDate']; + if ( strtotime( $start_date ) && strtotime( $end_date ) ) { + $compare_start_date = $data['compareStartDate']; + $compare_end_date = $data['compareEndDate']; + $date_ranges[] = array( $start_date, $end_date ); + + // When using multiple date ranges, it changes the structure of the response: + // Aggregate properties (minimum, maximum, totals) will have an entry per date range. + // The rows property will have additional row entries for each date range. + if ( strtotime( $compare_start_date ) && strtotime( $compare_end_date ) ) { + $date_ranges[] = array( $compare_start_date, $compare_end_date ); + } + } else { + // Default the date range to the last 28 days. + $date_ranges[] = $this->parse_date_range( 'last-28-days', 1 ); + } + + $date_ranges = array_map( + function ( $date_range ) { + list ( $start_date, $end_date ) = $date_range; + $date_range = new Google_Service_AnalyticsData_DateRange(); + $date_range->setStartDate( $start_date ); + $date_range->setEndDate( $end_date ); + + return $date_range; + }, + $date_ranges + ); + $request->setDateRanges( $date_ranges ); + + $metrics = $data['metrics']; + if ( is_string( $metrics ) || is_array( $metrics ) ) { + if ( is_string( $metrics ) ) { + $metrics = explode( ',', $data['metrics'] ); + } elseif ( is_array( $metrics ) && ! wp_is_numeric_array( $metrics ) ) { // If single object is passed. + $metrics = array( $metrics ); + } + + $metrics = array_filter( + array_map( + function ( $metric_def ) { + $metric = new Google_Service_AnalyticsData_Metric(); + + if ( is_string( $metric_def ) ) { + $metric->setName( $metric_def ); + } elseif ( is_array( $metric_def ) && ! empty( $metric_def['name'] ) ) { + $metric->setName( $metric_def['name'] ); + if ( ! empty( $metric_def['expression'] ) ) { + $metric->setExpression( $metric_def['expression'] ); + } + } else { + return null; + } + + return $metric; + }, + array_filter( $metrics ) + ) + ); + + if ( ! empty( $metrics ) ) { + try { + $this->validate_report_metrics( $metrics ); + } catch ( Invalid_Report_Metrics_Exception $exception ) { + return new WP_Error( + 'invalid_analytics_4_report_metrics', + $exception->getMessage() + ); + } + + $request->setMetrics( $metrics ); + } + } + + // Order by. + $orderby = $this->parse_reporting_orderby( $data['orderby'] ); + if ( ! empty( $orderby ) ) { + $request->setOrderBys( $orderby ); + } + + // Ensure the total, minimum and maximum metric aggregations are included in order to match what is returned by the UA reports. We may wish to make this optional in future. + $request->setMetricAggregations( + array( + 'TOTAL', + 'MINIMUM', + 'MAXIMUM', + ) + ); + + return $this->get_analyticsdata_service()->properties->runReport( self::normalize_property_id( $option['propertyID'] ), $request ); + } + + /** + * Parses the orderby value of the data request into an array of AnalyticsData OrderBy object instances. + * + * @since n.e.x.t + * + * @param array|null $orderby Data request orderby value. + * @return Google_Service_AnalyticsData_OrderBy[] An array of AnalyticsData OrderBy objects. + */ + protected function parse_reporting_orderby( $orderby ) { + if ( empty( $orderby ) || ! is_array( $orderby ) ) { + return array(); + } + + $results = array_map( + function ( $order_def ) { + $order_def = array_merge( + array( + 'fieldName' => '', + 'sortOrder' => '', + ), + (array) $order_def + ); + + if ( empty( $order_def['fieldName'] ) || empty( $order_def['sortOrder'] ) ) { + return null; + } + + $metric_order_by = new Google_Service_AnalyticsData_MetricOrderBy(); + $metric_order_by->setMetricName( $order_def['fieldName'] ); + $order_by = new Google_Service_AnalyticsData_OrderBy(); + $order_by->setMetric( $metric_order_by ); + $order_by->setDesc( 'DESCENDING' === $order_def['sortOrder'] ); + + return $order_by; + }, + // When just object is passed we need to convert it to an array of objects. + wp_is_numeric_array( $orderby ) ? $orderby : array( $orderby ) + ); + + $results = array_filter( $results ); + $results = array_values( $results ); + + return $results; + } + + /** + * Creates a new Analytics 4 site request for the current site and given arguments. + * + * @since n.e.x.t + * + * @param string $property_id Analytics 4 property ID. + * @param array $args { + * Optional. Additional arguments. + * + * @type array $dimensions List of request dimensions. Default empty array. + * @type Google_Service_AnalyticsData_FilterExpression[] $dimension_filters List of dimension filter instances for the specified request dimensions. Default empty array. + * @type string $start_date Start date in 'Y-m-d' format. Default empty string. + * @type string $end_date End date in 'Y-m-d' format. Default empty string. + * @type string $page Specific page URL to filter by. Default empty string. + * @type int $row_limit Limit of rows to return. Default empty string. + * } + * @return Google_Service_AnalyticsData_RunReportRequest|WP_Error Analytics 4 site request instance. + */ + protected function create_analytics_site_data_request( $property_id, array $args = array() ) { + $args = wp_parse_args( + $args, + array( + 'dimensions' => array(), + 'dimension_filters' => array(), + 'start_date' => '', + 'end_date' => '', + 'page' => '', + 'row_limit' => '', + ) + ); + + $request = new Google_Service_AnalyticsData_RunReportRequest(); + $request->setProperty( self::normalize_property_id( $property_id ) ); + + $request->setKeepEmptyRows( true ); + + if ( ! empty( $args['dimensions'] ) ) { + $request->setDimensions( (array) $args['dimensions'] ); + } + + if ( ! empty( $args['start_date'] ) && ! empty( $args['end_date'] ) ) { + $date_range = new Google_Service_AnalyticsData_DateRange(); + $date_range->setStartDate( $args['start_date'] ); + $date_range->setEndDate( $args['end_date'] ); + $request->setDateRanges( array( $date_range ) ); + } + + $dimension_filter_expressions = array(); + + $hostnames = $this->permute_site_hosts( URL::parse( $this->context->get_reference_site_url(), PHP_URL_HOST ) ); + + $dimension_in_list_filter = new Google_Service_AnalyticsData_InListFilter(); + $dimension_in_list_filter->setValues( $hostnames ); + $dimension_filter = new Google_Service_AnalyticsData_Filter(); + $dimension_filter->setFieldName( 'hostName' ); + $dimension_filter->setInListFilter( $dimension_in_list_filter ); + $dimension_filter_expression = new Google_Service_AnalyticsData_FilterExpression(); + $dimension_filter_expression->setFilter( $dimension_filter ); + $dimension_filter_expressions[] = $dimension_filter_expression; + + if ( ! empty( $args['dimension_filters'] ) ) { + $dimension_filter_expressions = array_merge( $dimension_filter_expressions, $args['dimension_filters'] ); + } + + if ( ! empty( $args['page'] ) ) { + $args['page'] = str_replace( trim( $this->context->get_reference_site_url(), '/' ), '', esc_url_raw( $args['page'] ) ); + $dimension_string_filter = new Google_Service_AnalyticsData_StringFilter(); + $dimension_string_filter->setMatchType( 'EXACT' ); + $dimension_string_filter->setValue( rawurldecode( $args['page'] ) ); + $dimension_filter = new Google_Service_AnalyticsData_Filter(); + $dimension_filter->setFieldName( 'pagePathPlusQueryString' ); + $dimension_filter->setStringFilter( $dimension_string_filter ); + $dimension_filter_expression = new Google_Service_AnalyticsData_FilterExpression(); + $dimension_filter_expression->setFilter( $dimension_filter ); + $dimension_filter_expressions[] = $dimension_filter_expression; + } + + $dimension_filter_expression_list = new Google_Service_AnalyticsData_FilterExpressionList(); + $dimension_filter_expression_list->setExpressions( $dimension_filter_expressions ); + $dimension_filter_expression = new Google_Service_AnalyticsData_FilterExpression(); + $dimension_filter_expression->setAndGroup( $dimension_filter_expression_list ); + $request->setDimensionFilter( $dimension_filter_expression ); + + if ( ! empty( $args['row_limit'] ) ) { + $request->setLimit( $args['row_limit'] ); + } + + return $request; + } + + /** + * Validates the report metrics. + * + * @since n.e.x.t + * + * @param Google_Service_AnalyticsData_Metric[] $metrics The metrics to validate. + * @throws Invalid_Report_Metrics_Exception Thrown if the metrics are invalid. + */ + protected function validate_report_metrics( $metrics ) { + if ( false === $this->is_using_shared_credentials ) { + return; + } + + $valid_metrics = apply_filters( + 'googlesitekit_shareable_analytics_4_metrics', + array( + // TODO: Add metrics to this allow-list as they are used in the plugin. + ) + ); + + $invalid_metrics = array_diff( + array_map( + function ( $metric ) { + // If there is an expression, it means the name is there as an alias, otherwise the name should be a valid metric name. + // Therefore, the expression takes precedence to the name for the purpose of allow-list validation. + return ! empty( $metric->getExpression() ) ? $metric->getExpression() : $metric->getName(); + }, + $metrics + ), + $valid_metrics + ); + + if ( count( $invalid_metrics ) > 0 ) { + $message = count( $invalid_metrics ) > 1 ? sprintf( + /* translators: %s: is replaced with a comma separated list of the invalid metrics. */ + __( + 'Unsupported metrics requested: %s', + 'google-site-kit' + ), + join( + /* translators: used between list items, there is a space after the comma. */ + __( ', ', 'google-site-kit' ), + $invalid_metrics + ) + ) : sprintf( + /* translators: %s: is replaced with the invalid metric. */ + __( + 'Unsupported metric requested: %s', + 'google-site-kit' + ), + $invalid_metrics + ); + + throw new Invalid_Report_Metrics_Exception( $message ); + } + } + + /** + * Validates the report dimensions. + * + * @since n.e.x.t + * + * @param Google_Service_AnalyticsData_Dimension[] $dimensions The dimensions to validate. + * @throws Invalid_Report_Dimensions_Exception Thrown if the dimensions are invalid. + */ + protected function validate_report_dimensions( $dimensions ) { + if ( false === $this->is_using_shared_credentials ) { + return; + } + + $valid_dimensions = apply_filters( + 'googlesitekit_shareable_analytics_4_dimensions', + array( + // TODO: Add dimensions to this allow-list as they are used in the plugin. + ) + ); + + $invalid_dimensions = array_diff( + array_map( + function ( $dimension ) { + return $dimension->getName(); + }, + $dimensions + ), + $valid_dimensions + ); + + if ( count( $invalid_dimensions ) > 0 ) { + $message = count( $invalid_dimensions ) > 1 ? sprintf( + /* translators: %s: is replaced with a comma separated list of the invalid dimensions. */ + __( + 'Unsupported dimensions requested: %s', + 'google-site-kit' + ), + join( + /* translators: used between list items, there is a space after the comma. */ + __( ', ', 'google-site-kit' ), + $invalid_dimensions + ) + ) : sprintf( + /* translators: %s: is replaced with the invalid dimension. */ + __( + 'Unsupported dimension requested: %s', + 'google-site-kit' + ), + $invalid_dimensions + ); + + throw new Invalid_Report_Dimensions_Exception( $message ); + } + } } diff --git a/tests/phpunit/includes/UserAuthenticationTrait.php b/tests/phpunit/includes/UserAuthenticationTrait.php new file mode 100644 index 00000000000..2343fe0a1a1 --- /dev/null +++ b/tests/phpunit/includes/UserAuthenticationTrait.php @@ -0,0 +1,34 @@ +set( + array( + 'access_token' => $access_token, + ) + ); + } +} diff --git a/tests/phpunit/integration/Modules/Analytics_4Test.php b/tests/phpunit/integration/Modules/Analytics_4Test.php index e97ce7a96cb..94c45213065 100644 --- a/tests/phpunit/integration/Modules/Analytics_4Test.php +++ b/tests/phpunit/integration/Modules/Analytics_4Test.php @@ -11,12 +11,18 @@ namespace Google\Site_Kit\Tests\Modules; use Google\Site_Kit\Context; +use Google\Site_Kit\Core\Authentication\Authentication; +use Google\Site_Kit\Core\Dismissals\Dismissed_Items; use Google\Site_Kit\Core\Modules\Module; +use Google\Site_Kit\Core\Modules\Module_Sharing_Settings; use Google\Site_Kit\Core\Modules\Module_With_Owner; use Google\Site_Kit\Core\Modules\Module_With_Scopes; use Google\Site_Kit\Core\Modules\Module_With_Settings; use Google\Site_Kit\Core\Modules\Module_With_Service_Entity; +use Google\Site_Kit\Core\Modules\Modules; +use Google\Site_Kit\Core\Permissions\Permissions; use Google\Site_Kit\Core\Storage\Options; +use Google\Site_Kit\Core\Storage\User_Options; use Google\Site_Kit\Modules\Analytics_4; use Google\Site_Kit\Modules\Analytics_4\Settings; use Google\Site_Kit\Tests\Core\Modules\Module_With_Owner_ContractTests; @@ -25,11 +31,13 @@ use Google\Site_Kit\Tests\Core\Modules\Module_With_Settings_ContractTests; use Google\Site_Kit\Tests\FakeHttpClient; use Google\Site_Kit\Tests\TestCase; +use Google\Site_Kit\Tests\UserAuthenticationTrait; use Google\Site_Kit_Dependencies\Google\Service\GoogleAnalyticsAdmin\GoogleAnalyticsAdminV1betaDataStream; use Google\Site_Kit_Dependencies\Google\Service\GoogleAnalyticsAdmin\GoogleAnalyticsAdminV1betaDataStreamWebStreamData; use Google\Site_Kit_Dependencies\GuzzleHttp\Message\Request; use Google\Site_Kit_Dependencies\GuzzleHttp\Message\Response; use Google\Site_Kit_Dependencies\GuzzleHttp\Stream\Stream; +use WP_User; /** * @group Modules @@ -40,6 +48,7 @@ class Analytics_4Test extends TestCase { use Module_With_Settings_ContractTests; use Module_With_Owner_ContractTests; use Module_With_Service_Entity_ContractTests; + use UserAuthenticationTrait; /** * Context object. @@ -48,6 +57,27 @@ class Analytics_4Test extends TestCase { */ private $context; + /** + * User object. + * + * @var WP_User + */ + private $user; + + /** + * User Options object. + * + * @var User_Options + */ + private $user_options; + + /** + * Authentication object. + * + * @var Authentication + */ + private $authentication; + /** * Analytics 4 object. * @@ -55,11 +85,23 @@ class Analytics_4Test extends TestCase { */ private $analytics; + /** + * Fake HTTP request handler calls. + * + * @var array + */ + private $request_handler_calls; + public function set_up() { parent::set_up(); - $this->context = new Context( GOOGLESITEKIT_PLUGIN_MAIN_FILE ); - $this->analytics = new Analytics_4( $this->context ); + $this->context = new Context( GOOGLESITEKIT_PLUGIN_MAIN_FILE ); + $options = new Options( $this->context ); + $this->user = $this->factory()->user->create_and_get( array( 'role' => 'administrator' ) ); + $this->user_options = new User_Options( $this->context, $this->user->ID ); + $this->authentication = new Authentication( $this->context, $options, $this->user_options ); + $this->analytics = new Analytics_4( $this->context, $options, $this->user_options, $this->authentication ); + wp_set_current_user( $this->user->ID ); } public function test_register() { @@ -206,6 +248,7 @@ public function test_get_datapoints() { 'create-webdatastream', 'properties', 'property', + 'report', 'webdatastreams', 'webdatastreams-batch', ), @@ -213,6 +256,811 @@ public function test_get_datapoints() { ); } + /** + * @dataProvider data_access_token + * + * When an access token is provided, the user will be authenticated for the test. + * + * @param string $access_token Access token, or empty string if none. + */ + public function test_get_report( $access_token ) { + $this->setup_user_authentication( $access_token ); + + $property_id = '123456789'; + + $this->analytics->get_settings()->merge( + array( + 'propertyID' => $property_id, + ) + ); + + // Grant scopes so request doesn't fail. + $this->authentication->get_oauth_client()->set_granted_scopes( + $this->analytics->get_scopes() + ); + + $http_client = $this->create_fake_http_client( $property_id ); + $this->analytics->get_client()->setHttpClient( $http_client ); + $this->analytics->register(); + + // Fetch a report that exercises all input parameters, barring the alternative date range, + // metric and dimension formats which are tested separately. + $data = $this->analytics->get_data( + 'report', + array( + // Note, metrics is a required parameter. + 'metrics' => array( + // Provide metrics in both string and array formats. + 'sessions', + array( + 'name' => 'total', + 'expression' => 'totalUsers', + ), + ), + // All other parameters are optional. + 'url' => 'https://example.org/some-page-here/', + 'limit' => 321, + 'startDate' => '2022-11-02', + 'endDate' => '2022-11-04', + 'compareStartDate' => '2022-11-01', + 'compareEndDate' => '2022-11-02', + 'dimensions' => array( + // Provide dimensions in both string and array formats. + 'sessionDefaultChannelGrouping', + array( + 'name' => 'pageTitle', + ), + ), + 'dimensionFilters' => array( + // Provide dimension filters with single and multiple values. + 'sessionDefaultChannelGrouping' => 'Organic Search', + 'pageTitle' => array( 'Title Foo', 'Title Bar' ), + ), + 'orderby' => array( + array( + 'fieldName' => 'sessions', + 'sortOrder' => 'DESCENDING', + ), + array( + 'fieldName' => 'total', + 'sortOrder' => 'ASCENDING', + ), + ), + ) + ); + + $this->assertNotWPError( $data ); + + // Verify the reports are returned by checking a metric value. + $this->assertEquals( 'some-value', $data['modelData'][0]['rows'][0]['metricValues'][0]['value'] ); + + // Verify the request URL and params were correctly generated. + $this->assertCount( 1, $this->request_handler_calls ); + + $request_url = $this->request_handler_calls[0]['url']; + + $this->assertEquals( 'analyticsdata.googleapis.com', $request_url['host'] ); + $this->assertEquals( '/v1beta/properties/123456789:runReport', $request_url['path'] ); + + $request_params = $this->request_handler_calls[0]['params']; + + // Verify the request params that are set by default. + $this->assertEquals( + 'properties/123456789', + $request_params['property'] + ); + + $this->assertEquals( + 1, + $request_params['keepEmptyRows'] + ); + + $this->assertEquals( + array( + 'TOTAL', + 'MINIMUM', + 'MAXIMUM', + ), + $request_params['metricAggregations'] + ); + + // Verify the request params that are derived from the input parameters. + $this->assertEquals( + array( + array( + 'name' => 'sessions', + ), + array( + 'name' => 'total', + 'expression' => 'totalUsers', + ), + ), + $request_params['metrics'] + ); + + $this->assertEquals( + array( + array( + 'startDate' => '2022-11-02', + 'endDate' => '2022-11-04', + ), + array( + 'startDate' => '2022-11-01', + 'endDate' => '2022-11-02', + ), + ), + $request_params['dateRanges'] + ); + + $this->assertEquals( + 321, + $request_params['limit'] + ); + + $this->assertEquals( + array( + array( + 'name' => 'sessionDefaultChannelGrouping', + ), + array( + 'name' => 'pageTitle', + ), + ), + $request_params['dimensions'] + ); + + $this->assertEquals( + array( + 'andGroup' => array( + 'expressions' => array( + // Verify the default page filter is correct. + array( + 'filter' => array( + 'fieldName' => 'hostName', + 'inListFilter' => array( + 'values' => array( + 'example.org', + 'www.example.org', + ), + ), + ), + ), + // Verify the single-value dimension filter is correct. + array( + 'filter' => array( + 'fieldName' => 'sessionDefaultChannelGrouping', + 'stringFilter' => array( + 'matchType' => 'EXACT', + 'value' => 'Organic Search', + ), + ), + ), + // Verify the multi-value dimension filter is correct. + array( + 'filter' => array( + 'fieldName' => 'pageTitle', + 'inListFilter' => array( + 'values' => array( 'Title Foo', 'Title Bar' ), + ), + ), + ), + // Verify the URL filter is correct. + array( + 'filter' => array( + 'fieldName' => 'pagePathPlusQueryString', + 'stringFilter' => array( + 'matchType' => 'EXACT', + 'value' => 'https://example.org/some-page-here/', + ), + ), + ), + ), + ), + ), + $request_params['dimensionFilter'] + ); + + $this->assertEquals( + array( + array( + 'metric' => array( + 'metricName' => 'sessions', + ), + 'desc' => '1', + ), + array( + 'metric' => array( + 'metricName' => 'total', + ), + 'desc' => '', + ), + ), + $request_params['orderBys'] + ); + } + + /** + * @dataProvider data_access_token + * + * When an access token is provided, the user will be authenticated for the test. + * + * @param string $access_token Access token, or empty string if none. + */ + public function test_get_report__default_date_range( $access_token ) { + $this->setup_user_authentication( $access_token ); + + $property_id = '123456789'; + + $this->analytics->get_settings()->merge( + array( + 'propertyID' => $property_id, + ) + ); + + // Grant scopes so request doesn't fail. + $this->authentication->get_oauth_client()->set_granted_scopes( + $this->analytics->get_scopes() + ); + + $http_client = $this->create_fake_http_client( $property_id ); + $this->analytics->get_client()->setHttpClient( $http_client ); + $this->analytics->register(); + + $this->analytics->get_data( + 'report', + array( + // Note, metrics is a required parameter. + 'metrics' => array( + array( 'name' => 'sessions' ), + ), + ) + ); + + $request_params = $this->request_handler_calls[0]['params']; + + // Verify the default date range is correct. + $this->assertEquals( + array( + array( + 'startDate' => $this->days_ago_date_string( 28 ), + 'endDate' => $this->days_ago_date_string( 1 ), + ), + ), + $request_params['dateRanges'] + ); + } + + /** + * @dataProvider data_access_token + * + * When an access token is provided, the user will be authenticated for the test. + * + * @param string $access_token Access token, or empty string if none. + */ + public function test_get_report__metrics_as_string( $access_token ) { + $this->setup_user_authentication( $access_token ); + + $property_id = '123456789'; + + $this->analytics->get_settings()->merge( + array( + 'propertyID' => $property_id, + ) + ); + + // Grant scopes so request doesn't fail. + $this->authentication->get_oauth_client()->set_granted_scopes( + $this->analytics->get_scopes() + ); + + $http_client = $this->create_fake_http_client( $property_id ); + $this->analytics->get_client()->setHttpClient( $http_client ); + $this->analytics->register(); + + $this->analytics->get_data( + 'report', + array( + // Note, metrics is a required parameter. + 'metrics' => 'sessions,totalUsers', + ) + ); + + $request_params = $this->request_handler_calls[0]['params']; + + $this->assertEquals( + array( + array( + 'name' => 'sessions', + ), + array( + 'name' => 'totalUsers', + ), + ), + $request_params['metrics'] + ); + } + + /** + * @dataProvider data_access_token + * + * When an access token is provided, the user will be authenticated for the test. + * + * @param string $access_token Access token, or empty string if none. + */ + public function test_get_report__metrics_as_single_object( $access_token ) { + $this->setup_user_authentication( $access_token ); + + $property_id = '123456789'; + + $this->analytics->get_settings()->merge( + array( + 'propertyID' => $property_id, + ) + ); + + // Grant scopes so request doesn't fail. + $this->authentication->get_oauth_client()->set_granted_scopes( + $this->analytics->get_scopes() + ); + + $http_client = $this->create_fake_http_client( $property_id ); + $this->analytics->get_client()->setHttpClient( $http_client ); + $this->analytics->register(); + + $this->analytics->get_data( + 'report', + array( + // Note, metrics is a required parameter. + 'metrics' => array( + 'name' => 'total', + 'expression' => 'totalUsers', + ), + ) + ); + + $request_params = $this->request_handler_calls[0]['params']; + + $this->assertEquals( + array( + array( + 'name' => 'total', + 'expression' => 'totalUsers', + ), + ), + $request_params['metrics'] + ); + } + + /** + * @dataProvider data_access_token + * + * When an access token is provided, the user will be authenticated for the test. + * + * @param string $access_token Access token, or empty string if none. + */ + public function test_get_report__dimensions_as_string( $access_token ) { + $this->setup_user_authentication( $access_token ); + + $property_id = '123456789'; + + $this->analytics->get_settings()->merge( + array( + 'propertyID' => $property_id, + ) + ); + + // Grant scopes so request doesn't fail. + $this->authentication->get_oauth_client()->set_granted_scopes( + $this->analytics->get_scopes() + ); + + $http_client = $this->create_fake_http_client( $property_id ); + $this->analytics->get_client()->setHttpClient( $http_client ); + $this->analytics->register(); + + $this->analytics->get_data( + 'report', + array( + // Note, metrics is a required parameter. + 'metrics' => 'sessions', + 'dimensions' => 'sessionDefaultChannelGrouping,pageTitle', + ) + ); + + $request_params = $this->request_handler_calls[0]['params']; + + $this->assertEquals( + array( + array( + 'name' => 'sessionDefaultChannelGrouping', + ), + array( + 'name' => 'pageTitle', + ), + ), + $request_params['dimensions'] + ); + } + + /** + * @dataProvider data_access_token + * + * When an access token is provided, the user will be authenticated for the test. + * + * @param string $access_token Access token, or empty string if none. + */ + public function test_get_report__dimensions_as_single_object( $access_token ) { + $this->setup_user_authentication( $access_token ); + + $property_id = '123456789'; + + $this->analytics->get_settings()->merge( + array( + 'propertyID' => $property_id, + ) + ); + + // Grant scopes so request doesn't fail. + $this->authentication->get_oauth_client()->set_granted_scopes( + $this->analytics->get_scopes() + ); + + $http_client = $this->create_fake_http_client( $property_id ); + $this->analytics->get_client()->setHttpClient( $http_client ); + $this->analytics->register(); + + $this->analytics->get_data( + 'report', + array( + // Note, metrics is a required parameter. + 'metrics' => 'sessions', + 'dimensions' => array( + 'name' => 'pageTitle', + ), + ) + ); + + $request_params = $this->request_handler_calls[0]['params']; + + $this->assertEquals( + array( + array( + 'name' => 'pageTitle', + ), + ), + $request_params['dimensions'] + ); + } + + /** + * @dataProvider data_access_token + * + * When an access token is provided, the user will be authenticated for the test. + * + * @param string $access_token Access token, or empty string if none. + */ + public function test_report__insufficient_permissions( $access_token ) { + $this->setup_user_authentication( $access_token ); + + $data = $this->analytics->get_data( 'report', array() ); + + $this->assertWPErrorWithMessage( 'Site Kit can’t access the relevant data from Analytics 4 because you haven’t granted all permissions requested during setup.', $data ); + $this->assertEquals( 'missing_required_scopes', $data->get_error_code() ); + } + + /** + * @dataProvider data_access_token + * + * When an access token is provided, the user will be authenticated for the test. + * + * @param string $access_token Access token, or empty string if none. + */ + public function test_report__no_metrics( $access_token ) { + $this->setup_user_authentication( $access_token ); + + $property_id = '123456789'; + + $this->analytics->get_settings()->merge( + array( + 'propertyID' => $property_id, + ) + ); + + $this->authentication->get_oauth_client()->set_granted_scopes( + $this->analytics->get_scopes() + ); + + $data = $this->analytics->get_data( 'report', array() ); + + $this->assertWPErrorWithMessage( 'Request parameter is empty: metrics.', $data ); + $this->assertEquals( 'missing_required_param', $data->get_error_code() ); + $this->assertEquals( array( 'status' => 400 ), $data->get_error_data( 'missing_required_param' ) ); + } + + public function test_report__metric_validation() { + $this->enable_feature( 'dashboardSharing' ); + + $this->context = new Context( GOOGLESITEKIT_PLUGIN_MAIN_FILE ); + $options = new Options( $this->context ); + $this->user = $this->factory()->user->create_and_get( array( 'role' => 'administrator' ) ); + $this->user_options = new User_Options( $this->context, $this->user->ID ); + $this->authentication = new Authentication( $this->context, $options, $this->user_options ); + $this->analytics = new Analytics_4( $this->context, $options, $this->user_options, $this->authentication ); + + // Re-register Permissions after enabling the dashboardSharing feature to include dashboard sharing capabilities. + // TODO: Remove this when `dashboardSharing` feature flag is removed. + $modules = new Modules( $this->context, null, $this->user_options, $this->authentication ); + $permissions = new Permissions( $this->context, $this->authentication, $modules, $this->user_options, new Dismissed_Items( $this->user_options ) ); + $permissions->register(); + + $property_id = '123456789'; + + $this->analytics->get_settings()->merge( + array( + 'propertyID' => $property_id, + ) + ); + + $this->set_shareable_metrics( 'sessions', 'totalUsers' ); + + $this->enable_shared_credentials(); + + $data = $this->analytics->get_data( + 'report', + array( + // Note, metrics is a required parameter. + 'metrics' => array( + array( 'name' => 'sessions' ), + array( 'name' => 'totalUsers' ), + array( 'name' => 'invalidMetric' ), + array( 'name' => 'anotherInvalidMetric' ), + ), + ) + ); + + $this->assertWPErrorWithMessage( 'Unsupported metrics requested: invalidMetric, anotherInvalidMetric', $data ); + $this->assertEquals( 'invalid_analytics_4_report_metrics', $data->get_error_code() ); + } + + public function test_report__dimension_validation() { + $this->enable_feature( 'dashboardSharing' ); + + $this->context = new Context( GOOGLESITEKIT_PLUGIN_MAIN_FILE ); + $options = new Options( $this->context ); + $this->user = $this->factory()->user->create_and_get( array( 'role' => 'administrator' ) ); + $this->user_options = new User_Options( $this->context, $this->user->ID ); + $this->authentication = new Authentication( $this->context, $options, $this->user_options ); + $this->analytics = new Analytics_4( $this->context, $options, $this->user_options, $this->authentication ); + + // Re-register Permissions after enabling the dashboardSharing feature to include dashboard sharing capabilities. + // TODO: Remove this when `dashboardSharing` feature flag is removed. + $modules = new Modules( $this->context, null, $this->user_options, $this->authentication ); + $permissions = new Permissions( $this->context, $this->authentication, $modules, $this->user_options, new Dismissed_Items( $this->user_options ) ); + $permissions->register(); + + $property_id = '123456789'; + + $this->analytics->get_settings()->merge( + array( + 'propertyID' => $property_id, + ) + ); + + $this->set_shareable_metrics( 'sessions' ); + $this->set_shareable_dimensions( 'date', 'pageTitle' ); + + $this->enable_shared_credentials(); + + $data = $this->analytics->get_data( + 'report', + array( + // Note, metrics is a required parameter. + 'metrics' => array( + array( 'name' => 'sessions' ), + ), + 'dimensions' => array( 'date', 'pageTitle', 'invalidDimension', 'anotherInvalidDimension' ), + ) + ); + + $this->assertWPErrorWithMessage( 'Unsupported dimensions requested: invalidDimension, anotherInvalidDimension', $data ); + $this->assertEquals( 'invalid_analytics_4_report_dimensions', $data->get_error_code() ); + } + + /** + * @dataProvider data_access_token + * + * When an access token is provided, the user will be authenticated for the test. + * + * @param string $access_token Access token, or empty string if none. + */ + public function test_report__no_property_id( $access_token ) { + $this->setup_user_authentication( $access_token ); + + $this->authentication->get_oauth_client()->set_granted_scopes( + $this->analytics->get_scopes() + ); + + $data = $this->analytics->get_data( + 'report', + array( + // Note, metrics is a required parameter. + 'metrics' => array( + array( 'name' => 'sessions' ), + ), + ) + ); + + $this->assertWPErrorWithMessage( 'No connected Google Analytics 4 property ID.', $data ); + $this->assertEquals( 'missing_required_setting', $data->get_error_code() ); + $this->assertEquals( array( 'status' => 500 ), $data->get_error_data( 'missing_required_setting' ) ); + } + + /** + * Returns a date string for the given number of days ago. + * + * @param int $days_ago The number of days ago. + * @return string The date string, formatted as YYYY-MM-DD. + */ + protected function days_ago_date_string( $days_ago ) { + return gmdate( 'Y-m-d', strtotime( $days_ago . ' days ago' ) ); + } + + /** + * Sets the shareable metrics for the Analytics_4 module. + * + * @param string[] $metrics The metrics to set. + */ + protected function set_shareable_metrics( ...$metrics ) { + add_filter( + 'googlesitekit_shareable_analytics_4_metrics', + function() use ( $metrics ) { + return $metrics; + } + ); + } + + /** + * Sets the shareable dimensions for the Analytics_4 module. + * + * @param string[] $dimensions The dimensions to set. + */ + protected function set_shareable_dimensions( ...$dimensions ) { + add_filter( + 'googlesitekit_shareable_analytics_4_dimensions', + function() use ( $dimensions ) { + return $dimensions; + } + ); + } + + /** + * Creates a fake HTTP client with call tracking. + * + * @param string $property_id The GA4 property ID to use. + * @return FakeHttpClient The fake HTTP client. + */ + protected function create_fake_http_client( $property_id ) { + $this->request_handler_calls = array(); + + $http_client = new FakeHttpClient(); + $http_client->set_request_handler( + function ( Request $request ) use ( $property_id ) { + $url = parse_url( $request->getUrl() ); + $params = json_decode( (string) $request->getBody(), true ); + + $this->request_handler_calls[] = array( + 'url' => $url, + 'params' => $params, + ); + + if ( 'analyticsdata.googleapis.com' !== $url['host'] ) { + return new Response( 200 ); + } + + switch ( $url['path'] ) { + case "/v1beta/properties/$property_id:runReport": + // Return a mock report. + return new Response( + 200, + array(), + Stream::factory( + json_encode( + array( + 'kind' => 'analyticsData#runReport', + array( + 'rows' => array( + array( + 'metricValues' => array( + array( + 'value' => 'some-value', + ), + ), + ), + ), + ), + ) + ) + ) + ); + + default: + return new Response( 200 ); + } + } + ); + + return $http_client; + } + + /** + * Metrics and dimensions are only validated when using shared credentials. This helper method sets up the shared credentials scenario. + */ + protected function enable_shared_credentials() { + // Create a user to set as the Analytics 4 module owner. + $admin = $this->factory()->user->create_and_get( array( 'role' => 'administrator' ) ); + + $this->setup_user_authentication( 'valid-auth-token', $admin->ID ); + + // Ensure the new user has the necessary scopes to make the request. + $restore_user = $this->user_options->switch_user( $admin->ID ); + $this->authentication->get_oauth_client()->set_granted_scopes( + $this->analytics->get_scopes() + ); + $restore_user(); + + // Ensure the Analytics 4 module is connected and the owner ID is set. + update_option( + 'googlesitekit_analytics-4_settings', + array( + 'propertyID' => '123', + 'webDataStreamID' => '456', + 'measurementID' => 'G-789', + 'ownerID' => $admin->ID, + ) + ); + + // Ensure sharing is enabled for the Analytics 4 module. + add_option( + Module_Sharing_Settings::OPTION, + array( + 'analytics-4' => array( + 'sharedRoles' => $this->user->roles, + 'management' => 'owner', + ), + ) + ); + } + + /** + * Provides data for testing access states. + * + * @return array + */ + public function data_access_token() { + return array( + 'unauthenticated user' => array( '' ), + 'authenticated user' => array( 'valid-auth-token' ), + ); + } + + /** + * Sets up user authentication if an access token is provided. + * + * @param string $access_token The access token to use. + * @param int [$user_id] The user ID to set up authentication for. Will default to the current user. + */ + protected function setup_user_authentication( $access_token, $user_id = null ) { + if ( empty( $access_token ) ) { + return; + } + + if ( empty( $user_id ) ) { + $user_id = $this->user->ID; + } + + $this->set_user_access_token( $user_id, $access_token ); + } + /** * @return Module_With_Scopes */