diff --git a/src/API/Google/AdsCampaign.php b/src/API/Google/AdsCampaign.php index 6872561bbe..da7e718585 100644 --- a/src/API/Google/AdsCampaign.php +++ b/src/API/Google/AdsCampaign.php @@ -106,15 +106,17 @@ public function __construct( GoogleAdsClient $client, AdsCampaignBudget $budget, /** * Returns a list of campaigns with targeted locations retrieved from campaign criterion. * - * @param bool $exclude_removed Exclude removed campaigns (default true). - * @param bool $fetch_criterion Combine the campaign data with criterion data (default true). + * @param bool $exclude_removed Exclude removed campaigns (default true). + * @param bool $fetch_criterion Combine the campaign data with criterion data (default true). + * @param array $args Arguments for the Ads Campaign Query for example: per_page for limiting the number of results. + * @param bool $return_pagination_params Whether to return pagination params (default false). * * @return array * @throws ExceptionWithResponseData When an ApiException is caught. */ - public function get_campaigns( bool $exclude_removed = true, bool $fetch_criterion = true ): array { + public function get_campaigns( bool $exclude_removed = true, bool $fetch_criterion = true, $args = [], $return_pagination_params = false ): array { try { - $query = ( new AdsCampaignQuery() )->set_client( $this->client, $this->options->get_ads_id() ); + $query = ( new AdsCampaignQuery( $args ) )->set_client( $this->client, $this->options->get_ads_id() ); if ( $exclude_removed ) { $query->where( 'campaign.status', 'REMOVED', '!=' ); @@ -124,7 +126,10 @@ public function get_campaigns( bool $exclude_removed = true, bool $fetch_criteri $campaign_results = $query->get_results(); $converted_campaigns = []; - foreach ( $campaign_results->iterateAllElements() as $row ) { + /** @var Page $page */ + $page = $campaign_results->getPage(); + + foreach ( $page->getIterator() as $row ) { ++$campaign_count; $campaign = $this->convert_campaign( $row ); $converted_campaigns[ $campaign['id'] ] = $campaign; @@ -143,6 +148,16 @@ public function get_campaigns( bool $exclude_removed = true, bool $fetch_criteri $converted_campaigns = $this->combine_campaigns_and_campaign_criterion_results( $converted_campaigns ); } + if ( $return_pagination_params ) { + // Total results across all pages. + $total_results = $page->getResponseObject()->getTotalResultsCount(); + $next_page_token = $page->getNextPageToken(); + return [ + 'campaigns' => array_values( $converted_campaigns ), + 'total_results' => $total_results, + 'next_page_token' => $next_page_token, + ]; + } return array_values( $converted_campaigns ); } catch ( ApiException $e ) { do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ ); @@ -168,14 +183,14 @@ public function get_campaigns( bool $exclude_removed = true, bool $fetch_criteri */ public function get_campaign( int $id ): array { try { - $campaign_results = ( new AdsCampaignQuery() )->set_client( $this->client, $this->options->get_ads_id() ) + $campaign_results = ( new AdsCampaignQuery( [ 'per_page' => 1 ] ) )->set_client( $this->client, $this->options->get_ads_id() ) ->where( 'campaign.id', $id, '=' ) ->get_results(); $converted_campaigns = []; // Get only the first element from campaign results - foreach ( $campaign_results->iterateAllElements() as $row ) { + foreach ( $campaign_results->getPage()->getIterator() as $row ) { $campaign = $this->convert_campaign( $row ); $converted_campaigns[ $campaign['id'] ] = $campaign; break; diff --git a/src/API/Google/Query/AdsCampaignQuery.php b/src/API/Google/Query/AdsCampaignQuery.php index 658960a327..d0c2509998 100644 --- a/src/API/Google/Query/AdsCampaignQuery.php +++ b/src/API/Google/Query/AdsCampaignQuery.php @@ -12,10 +12,15 @@ */ class AdsCampaignQuery extends AdsQuery { + use ReportQueryTrait; + + /** * Query constructor. + * + * @param array $args Query arguments. */ - public function __construct() { + public function __construct( $args = [] ) { parent::__construct( 'campaign' ); $this->columns( [ @@ -27,5 +32,7 @@ public function __construct() { 'campaign_budget.amount_micros', ] ); + + $this->handle_query_args( $args ); } } diff --git a/src/API/Google/Query/AdsQuery.php b/src/API/Google/Query/AdsQuery.php index b957816f95..8caaf0758d 100644 --- a/src/API/Google/Query/AdsQuery.php +++ b/src/API/Google/Query/AdsQuery.php @@ -91,8 +91,16 @@ protected function query_results() { } $request = new SearchGoogleAdsRequest(); + // Allow us to get the total number of results for pagination. + $request->setReturnTotalResultsCount( true ); + + if ( ! empty( $this->search_args['pageSize'] ) ) { + $request->setPageSize( $this->search_args['pageSize'] ); + } + $request->setQuery( $this->build_query() ); $request->setCustomerId( $this->id ); + $this->results = $this->client->getGoogleAdsServiceClient()->search( $request ); } } diff --git a/src/API/Site/Controllers/Ads/CampaignController.php b/src/API/Site/Controllers/Ads/CampaignController.php index e9c220c15d..90d220117e 100644 --- a/src/API/Site/Controllers/Ads/CampaignController.php +++ b/src/API/Site/Controllers/Ads/CampaignController.php @@ -99,15 +99,33 @@ public function register_routes(): void { protected function get_campaigns_callback(): callable { return function ( Request $request ) { try { - $exclude_removed = $request->get_param( 'exclude_removed' ); + $exclude_removed = $request->get_param( 'exclude_removed' ); + $return_pagination_params = true; + $campaign_data = $this->ads_campaign->get_campaigns( $exclude_removed, true, $request->get_params(), $return_pagination_params ); - return array_map( + $campaigns = array_map( function ( $campaign ) use ( $request ) { $data = $this->prepare_item_for_response( $campaign, $request ); return $this->prepare_response_for_collection( $data ); }, - $this->ads_campaign->get_campaigns( $exclude_removed ) + $campaign_data['campaigns'] ); + + $response = rest_ensure_response( $campaigns ); + + $total_campaigns = (int) $campaign_data['total_results']; + $response->header( 'X-WP-Total', $total_campaigns ); + // If per_page is not set, then set it to total number of campaigns. + $per_page = $request->get_param( 'per_page' ) ?: $total_campaigns; + $max_pages = $per_page > 0 ? ceil( $total_campaigns / $per_page ) : 1; + $response->header( 'X-WP-TotalPages', (int) $max_pages ); + + if ( ! empty( $campaign_data['next_page_token'] ) ) { + $response->header( 'X-GLA-NextPageToken', $campaign_data['next_page_token'] ); + } + + return $response; + } catch ( Exception $e ) { return $this->response_from_exception( $e ); } @@ -320,6 +338,14 @@ public function get_collection_params(): array { 'default' => true, 'validate_callback' => 'rest_validate_request_arg', ], + 'per_page' => [ + 'description' => __( 'Maximum number of rows to be returned in result data.', 'google-listings-and-ads' ), + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 1000, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + ], ]; } diff --git a/tests/Tools/HelperTrait/GoogleAdsClientTrait.php b/tests/Tools/HelperTrait/GoogleAdsClientTrait.php index d600063b34..d3443d4470 100644 --- a/tests/Tools/HelperTrait/GoogleAdsClientTrait.php +++ b/tests/Tools/HelperTrait/GoogleAdsClientTrait.php @@ -56,6 +56,7 @@ use Google\Ads\GoogleAds\V16\Services\MutateOperation; use Google\Ads\GoogleAds\V16\Services\MutateAssetGroupResult; use Google\Ads\GoogleAds\V16\Services\MutateAssetResult; +use Google\Ads\GoogleAds\V16\Services\SearchGoogleAdsResponse; use Google\ApiCore\ApiException; use Google\ApiCore\Page; use Google\ApiCore\PagedListResponse; @@ -187,17 +188,32 @@ protected function generate_ads_multiple_query_mock( array $responses ) { * * @param array $campaigns_responses Set of campaign data to convert. * @param array $campaign_criterion_responses Set of campaign criterion data to convert. + * @param bool $assert_pagination Whether to assert pagination. */ - protected function generate_ads_campaign_query_mock( array $campaigns_responses, $campaign_criterion_responses ) { + protected function generate_ads_campaign_query_mock( array $campaigns_responses, $campaign_criterion_responses, $assert_pagination = false ) { $campaigns_row_mock = array_map( [ $this, 'generate_campaign_row_mock' ], $campaigns_responses ); $campaign_criterion_row_mock = array_map( [ $this, 'generate_campaign_criterion_row_mock' ], $campaign_criterion_responses ); $list_response = $this->createMock( PagedListResponse::class ); - $list_response->method( 'iterateAllElements' )->willReturnOnConsecutiveCalls( + $page = $this->createMock( Page::class ); + + if ( $assert_pagination ) { + $response_object = $this->createMock( SearchGoogleAdsResponse::class ); + $response_object->expects( $this->exactly( 1 ) )->method( 'getTotalResultsCount' )->willReturn( count( $campaigns_responses ) ); + $page->expects( $this->exactly( 1 ) )->method( 'getNextPageToken' )->willReturn( '' ); + $page->method( 'getResponseObject' )->willReturn( $response_object ); + } + + $page->method( 'getIterator' )->willReturn( $campaigns_row_mock, + ); + + $list_response->method( 'iterateAllElements' )->willReturn( $campaign_criterion_row_mock ); + $list_response->method( 'getPage' )->willReturn( $page ); + $this->service_client ->method( 'search' )->willReturn( $list_response ); } @@ -311,7 +327,9 @@ function ( MutateGoogleAdsRequest $request ) use ( $response, $label_id, $campai */ protected function generate_ads_campaign_query_mock_with_no_campaigns() { $list_response = $this->createMock( PagedListResponse::class ); - $list_response->method( 'iterateAllElements' )->willReturn( [] ); + $page = $this->createMock( Page::class ); + $page->method( 'getIterator' )->willReturn( [] ); + $list_response->method( 'getPage' )->willReturn( $page ); // Method search() will only being called once by AdsCampaignQuery // since there were no campaigns returned by AdsCampaignQuery, it diff --git a/tests/Unit/API/Google/AdsCampaignTest.php b/tests/Unit/API/Google/AdsCampaignTest.php index ad805863d0..74730c9998 100644 --- a/tests/Unit/API/Google/AdsCampaignTest.php +++ b/tests/Unit/API/Google/AdsCampaignTest.php @@ -141,6 +141,73 @@ public function test_get_campaigns() { $this->assertEquals( $campaigns_data, $this->campaign->get_campaigns() ); } + public function test_get_campaigns_with_search_args() { + $campaign_criterion_data = [ + [ + 'campaign_id' => self::TEST_CAMPAIGN_ID, + 'geo_target_constant' => 'geoTargetConstants/2158', + ], + [ + 'campaign_id' => 5678901234, + 'geo_target_constant' => 'geoTargetConstants/2344', + ], + [ + 'campaign_id' => 5678901234, + 'geo_target_constant' => 'geoTargetConstants/2826', + ], + ]; + + $campaigns_data = [ + [ + 'id' => self::TEST_CAMPAIGN_ID, + 'name' => 'Campaign One', + 'status' => 'paused', + 'type' => 'shopping', + 'amount' => 10, + 'country' => 'US', + 'targeted_locations' => [ 'TW' ], + ], + [ + 'id' => 5678901234, + 'name' => 'Campaign Two', + 'status' => 'enabled', + 'type' => 'performance_max', + 'amount' => 20, + 'country' => 'UK', + 'targeted_locations' => [ 'HK', 'GB' ], + ], + ]; + + $this->generate_ads_campaign_query_mock( $campaigns_data, $campaign_criterion_data, true ); + + $matcher = $this->exactly( 2 ); + $this->service_client + ->expects( $matcher ) + ->method( 'search' ) + ->willReturnCallback( + function ( $request ) use ( $matcher ) { + if ( $matcher->getInvocationCount() === 1 ) { + $this->assertEquals( 2, $request->getPageSize() ); // Campaigns + } + + if ( $matcher->getInvocationCount() === 2 ) { + $this->assertEquals( 0, $request->getPageSize() ); // Criterions + } + + return true; + } + ); + + $this->assertEquals( + [ + 'campaigns' => $campaigns_data, + 'total_results' => count( $campaigns_data ), + 'next_page_token' => '', + ], + $this->campaign->get_campaigns( true, true, [ 'per_page' => 2 ], true ) + ); + } + public function test_get_campaigns_with_nonexist_location_id() { $campaign_criterion_data = [ [ diff --git a/tests/Unit/API/Site/Controllers/Ads/CampaignControllerTest.php b/tests/Unit/API/Site/Controllers/Ads/CampaignControllerTest.php index 351a46b62d..de1d5c01ec 100644 --- a/tests/Unit/API/Site/Controllers/Ads/CampaignControllerTest.php +++ b/tests/Unit/API/Site/Controllers/Ads/CampaignControllerTest.php @@ -86,12 +86,23 @@ public function test_get_campaigns() { $this->ads_campaign->expects( $this->once() ) ->method( 'get_campaigns' ) - ->willReturn( $campaigns_data ); + ->willReturn( + [ + 'campaigns' => $campaigns_data, + 'total_results' => 2, + 'next_page_token' => '', + ] + ); $response = $this->do_request( self::ROUTE_CAMPAIGNS, 'GET' ); $this->assertEquals( $campaigns_data, $response->get_data() ); $this->assertEquals( 200, $response->get_status() ); + + $headers = $response->get_headers(); + $this->assertEquals( 2, $headers['X-WP-Total'] ); + $this->assertEquals( 1, $headers['X-WP-TotalPages'] ); + $this->assertArrayNotHasKey( 'X-GLA-NextPageToken', $headers ); } public function test_get_campaigns_converted_names() { @@ -144,12 +155,133 @@ public function test_get_campaigns_converted_names() { $this->ads_campaign->expects( $this->once() ) ->method( 'get_campaigns' ) ->with( false ) - ->willReturn( $campaigns_data ); + ->willReturn( + [ + 'campaigns' => $campaigns_data, + 'total_results' => 2, + 'next_page_token' => '', + ] + ); $response = $this->do_request( self::ROUTE_CAMPAIGNS, 'GET', [ 'exclude_removed' => false ] ); $this->assertEquals( $expected, $response->get_data() ); $this->assertEquals( 200, $response->get_status() ); + + $headers = $response->get_headers(); + $this->assertEquals( 2, $headers['X-WP-Total'] ); + $this->assertEquals( 1, $headers['X-WP-TotalPages'] ); + $this->assertArrayNotHasKey( 'X-GLA-NextPageToken', $headers ); + } + + public function test_get_campaigns_with_args() { + $campaigns_data = [ + [ + 'id' => self::TEST_CAMPAIGN_ID, + 'name' => 'Test Campaign', + 'status' => 'removed', + 'type' => 'shopping', + 'amount' => 10, + 'country' => 'US', + 'targeted_locations' => [], + ], + [ + 'id' => 5678901234, + 'name' => 'PMax: Test Campaign', + 'status' => 'enabled', + 'type' => 'performance_max', + 'amount' => 20, + 'country' => 'UK', + 'targeted_locations' => [], + ], + ]; + + $expected = [ + [ + 'id' => self::TEST_CAMPAIGN_ID, + 'name' => 'Test Campaign', + 'status' => 'removed', + 'type' => 'shopping', + 'amount' => 10, + 'country' => 'US', + 'targeted_locations' => [], + ], + [ + 'id' => 5678901234, + 'name' => 'PMax: Test Campaign', + 'status' => 'enabled', + 'type' => 'performance_max', + 'amount' => 20, + 'country' => 'UK', + 'targeted_locations' => [], + ], + ]; + + $matcher = $this->exactly( 3 ); + $this->ads_campaign->expects( $matcher ) // We will make two requests with different per_page values. + ->method( 'get_campaigns' ) + ->willReturnCallback( + function ( $exclude_removed, $fetch_criterion, $args ) use ( $matcher ) { + $this->assertTrue( $exclude_removed ); + $this->assertTrue( $fetch_criterion ); + + if ( $matcher->getInvocationCount() === 1 ) { + $this->assertEquals( 2, $args['per_page'] ); // First request. + } + + if ( $matcher->getInvocationCount() === 2 ) { + $this->assertEquals( 1, $args['per_page'] ); // Second request. + } + + return true; + } + )->willReturnOnConsecutiveCalls( + [ + 'campaigns' => $campaigns_data, + 'total_results' => 2, + 'next_page_token' => '', + ], + [ + 'campaigns' => [ $campaigns_data[0] ], + 'total_results' => 3, + 'next_page_token' => 'pageToken', + ], + [ + 'campaigns' => [], + 'total_results' => 0, + 'next_page_token' => '', + ] + ); + + // First request. + $response = $this->do_request( self::ROUTE_CAMPAIGNS, 'GET', [ 'per_page' => 2 ] ); + $this->assertEquals( $expected, $response->get_data() ); + $this->assertEquals( 200, $response->get_status() ); + + $headers = $response->get_headers(); + $this->assertEquals( 2, $headers['X-WP-Total'] ); + $this->assertEquals( 1, $headers['X-WP-TotalPages'] ); + $this->assertArrayNotHasKey( 'X-GLA-NextPageToken', $headers ); + + // Second request. + $response = $this->do_request( self::ROUTE_CAMPAIGNS, 'GET', [ 'per_page' => 1 ] ); + $this->assertEquals( [ $expected[0] ], $response->get_data() ); + $this->assertEquals( 200, $response->get_status() ); + + $headers = $response->get_headers(); + $this->assertEquals( 3, $headers['X-WP-Total'] ); + $this->assertEquals( 3, $headers['X-WP-TotalPages'] ); + $this->assertEquals( 'pageToken', $headers['X-GLA-NextPageToken'] ); + + // Third request. + $response = $this->do_request( self::ROUTE_CAMPAIGNS, 'GET', [ 'per_page' => 1 ] ); + $this->assertEquals( [], $response->get_data() ); + $this->assertEquals( 200, $response->get_status() ); + + $headers = $response->get_headers(); + $this->assertEquals( 0, $headers['X-WP-Total'] ); + $this->assertEquals( 0, $headers['X-WP-TotalPages'] ); + $this->assertArrayNotHasKey( 'X-GLA-NextPageToken', $headers ); } public function test_get_campaigns_with_api_exception() {