diff --git a/src/API/Google/MerchantReport.php b/src/API/Google/MerchantReport.php index e33013938e..c27dbf3a37 100644 --- a/src/API/Google/MerchantReport.php +++ b/src/API/Google/MerchantReport.php @@ -65,12 +65,12 @@ public function __construct( ShoppingContent $service, ProductHelper $product_he * @throws Exception If the product view report data can't be retrieved. */ public function get_product_view_report( $next_page_token = null ): array { - $batch_size = apply_filters( 'woocommerce_gla_product_view_report_page_size', 1000 ); + $batch_size = apply_filters( 'woocommerce_gla_product_view_report_page_size', 500 ); try { $product_view_data = [ - 'statuses' => [], - 'next_page' => null, + 'statuses' => [], + 'next_page_token' => null, ]; $query = new MerchantProductViewReportQuery( @@ -97,7 +97,7 @@ public function get_product_view_report( $next_page_token = null ): array { continue; } - $product_view_data['statuses'][] = [ + $product_view_data['statuses'][ (int) $wc_product_id ] = [ 'product_id' => $wc_product_id, 'status' => $mc_product_status, 'expiration_date' => $this->convert_shopping_content_date( $product_view->getExpirationDate() ), diff --git a/src/API/Google/Query/MerchantProductViewReportQuery.php b/src/API/Google/Query/MerchantProductViewReportQuery.php index 9519be3157..076928198f 100644 --- a/src/API/Google/Query/MerchantProductViewReportQuery.php +++ b/src/API/Google/Query/MerchantProductViewReportQuery.php @@ -45,7 +45,6 @@ protected function set_initial_columns() { $this->columns( [ 'id' => 'product_view.id', - 'offer_id' => 'product_view.offer_id', 'expiration_date' => 'product_view.expiration_date', 'status' => 'product_view.aggregated_destination_status', ] diff --git a/src/MerchantCenter/MerchantStatuses.php b/src/MerchantCenter/MerchantStatuses.php index c1de494e56..86bc2cdea7 100644 --- a/src/MerchantCenter/MerchantStatuses.php +++ b/src/MerchantCenter/MerchantStatuses.php @@ -610,6 +610,26 @@ protected function refresh_presync_product_issues(): void { $issue_query->update_or_insert( $product_issues ); } + /** + * Get the WC Products from the Product View statuses report. + * + * @param array $statuses statuses. + * @see MerchantReport::get_product_view_report + * + * @return WC_Product[] Associative array with the key as the product ID and the value the WooCommerce Product object. + */ + protected function get_wc_products_from_product_view_statuses( array $statuses ): array { + $product_repository = $this->container->get( ProductRepository::class ); + $products = $product_repository->find_by_ids( array_column( $statuses, 'product_id' ) ); + $map = []; + + foreach ( $products as $product ) { + $map[ $product->get_id() ] = $product; + } + + return $map; + } + /** * Process product status statistics. * @@ -622,9 +642,8 @@ public function process_product_statuses( array $statuses ): void { 'parents' => [], ]; - /** @var ProductHelper $product_helper */ - $product_helper = $this->container->get( ProductHelper::class ); $visibility_meta_key = $this->prefix_meta_key( ProductMetaHandler::KEY_VISIBILITY ); + $products = $this->get_wc_products_from_product_view_statuses( $statuses ); foreach ( $statuses as $product_status ) { @@ -636,13 +655,10 @@ public function process_product_statuses( array $statuses ): void { continue; } - if ( $this->product_is_expiring( $product_status['expiration_date'] ) ) { - $mc_product_status = MCStatus::EXPIRING; - } + $wc_product = $products[ $wc_product_id ] ?? null; - $wc_product = $product_helper->get_wc_product_by_wp_post( $wc_product_id ); - if ( ! $wc_product || 'product' !== substr( $wc_product->post_type, 0, 7 ) ) { - // Should never reach here since the products IDS are retrieved from postmeta. + if ( ! $wc_product ) { + // Skip if the product does not exist in WooCommerce. do_action( 'woocommerce_gla_debug_message', sprintf( 'Merchant Center product %s not found in this WooCommerce store.', $wc_product_id ), @@ -652,11 +668,15 @@ public function process_product_statuses( array $statuses ): void { } $this->product_data_lookup[ $wc_product_id ] = [ - 'name' => get_the_title( $wc_product ), - 'visibility' => get_post_meta( $wc_product_id, $visibility_meta_key ), - 'parent_id' => $wc_product->post_parent, + 'name' => $wc_product->get_name(), + 'visibility' => $wc_product->get_meta( $visibility_meta_key ), + 'parent_id' => $wc_product->get_parent_id(), ]; + if ( $this->product_is_expiring( $product_status['expiration_date'] ) ) { + $mc_product_status = MCStatus::EXPIRING; + } + // Products is used later for global product status statistics. $this->product_statuses['products'][ $wc_product_id ][ $mc_product_status ] = 1 + ( $this->product_statuses['products'][ $wc_product_id ][ $mc_product_status ] ?? 0 ); @@ -844,33 +864,14 @@ protected function update_products_meta_with_mc_status() { } } } - ksort( $new_product_statuses ); - - /** @var ProductMetaQueryHelper $product_meta_query_helper */ - $product_meta_query_helper = $this->container->get( ProductMetaQueryHelper::class ); - - // Get all MC statuses. - $current_product_statuses = $product_meta_query_helper->get_all_values( ProductMetaHandler::KEY_MC_STATUS ); - - // Format: product_id=>status. - $to_insert = []; - // Format: status=>[product_ids]. - $to_update = []; + ksort( $new_product_statuses ); foreach ( $new_product_statuses as $product_id => $new_status ) { - if ( ! isset( $current_product_statuses[ $product_id ] ) ) { - // MC status not in WC, insert. - $to_insert[ $product_id ] = $new_status; - } elseif ( $current_product_statuses[ $product_id ] !== $new_status ) { - // MC status not same as WC, update. - $to_update[ $new_status ][] = intval( $product_id ); - } - } - - // Insert and update changed MC Status postmeta. - $product_meta_query_helper->batch_insert_values( ProductMetaHandler::KEY_MC_STATUS, $to_insert ); - foreach ( $to_update as $status => $product_ids ) { - $product_meta_query_helper->batch_update_values( ProductMetaHandler::KEY_MC_STATUS, $status, $product_ids ); + // wc_get_product should return the cached product because it was fetched in the process_product_statuses method. + $product = wc_get_product( $product_id ); + $product->add_meta_data( $this->prefix_meta_key( ProductMetaHandler::KEY_MC_STATUS ), $new_status, true ); + // We use save_meta_data so we don't trigger the woocommerce_update_product hook and the Syncer Hooks. + $product->save_meta_data(); } } diff --git a/tests/Unit/API/Google/MerchantReportTest.php b/tests/Unit/API/Google/MerchantReportTest.php new file mode 100644 index 0000000000..92f84fdced --- /dev/null +++ b/tests/Unit/API/Google/MerchantReportTest.php @@ -0,0 +1,182 @@ +shopping_client = $this->createMock( ShoppingContent::class ); + $this->product_helper = $this->createMock( ProductHelper::class ); + $this->shopping_client->reports = $this->createMock( Reports::class ); + + $this->options = $this->createMock( OptionsInterface::class ); + $this->merchant_report = new MerchantReport( $this->shopping_client, $this->product_helper ); + $this->merchant_report->set_options_object( $this->options ); + + $this->options->method( 'get_merchant_id' )->willReturn( self::MERCHANT_ID ); + } + + /** + * Creates a product view with the given id, status and expiration date. + * + * @param string $mc_id The MC center id. + * @param string $status The status of the product. + * @param DateTime|null $expiration_date The expiration date of the product. + * + * @return ProductView + */ + protected function create_product_view_product_status( string $mc_id, string $status = 'ELIGIBLE', $expiration_date = null ): ProductView { + $expiration_date = $expiration_date ?? new DateTime( 'tomorrow', wp_timezone() ); + $product_view = new ProductView(); + $google_date = new GoogleDate(); + $google_date->setYear( $expiration_date->format( 'Y' ) ); + $google_date->setMonth( $expiration_date->format( 'm' ) ); + $google_date->setDay( $expiration_date->format( 'd' ) ); + $product_view->setExpirationDate( $google_date ); + $product_view->setAggregatedDestinationStatus( $status ); + $product_view->setId( $mc_id ); + return $product_view; + } + + public function test_get_product_view_report() { + $wc_product_id_1 = 882; + $wc_product_id_2 = 883; + $page_size = 800; + + add_filter( + 'woocommerce_gla_product_view_report_page_size', + function () use ( $page_size ) { + return $page_size; + } + ); + + $this->product_helper->method( 'get_wc_product_id' )->will( + $this->returnCallback( + function ( $mc_id ) use ( $wc_product_id_1, $wc_product_id_2 ) { + if ( $mc_id === 'online:en:ES:gla_' . $wc_product_id_1 ) { + return $wc_product_id_1; + } + + if ( $mc_id === 'online:en:ES:gla_' . $wc_product_id_2 ) { + return $wc_product_id_2; + } + + return 0; + } + ) + ); + + $product_view_1 = $this->create_product_view_product_status( 'online:en:ES:gla_' . $wc_product_id_1 ); + $product_view_2 = $this->create_product_view_product_status( 'online:en:ES:gla_' . $wc_product_id_2, 'NOT_ELIGIBLE_OR_DISAPPROVED' ); + $product_view_3 = $this->create_product_view_product_status( 'online:en:ES:external' ); + + $report_row_1 = new ReportRow(); + $report_row_1->setProductView( $product_view_1 ); + + $report_row_2 = new ReportRow(); + $report_row_2->setProductView( $product_view_2 ); + + $report_row_3 = new ReportRow(); + $report_row_3->setProductView( $product_view_3 ); + + $response = $this->createMock( SearchResponse::class ); + $response->expects( $this->once() ) + ->method( 'getResults' ) + ->willReturn( [ $report_row_1, $report_row_2, $report_row_3 ] ); + + $response->expects( $this->once() ) + ->method( 'getNextPageToken' ) + ->willReturn( null ); + + $search_request = new SearchRequest(); + $search_request->setQuery( + 'SELECT product_view.id,product_view.expiration_date,product_view.aggregated_destination_status FROM ProductView' + ); + + $search_request->setPageSize( $page_size ); + + $this->shopping_client->reports->expects( $this->once() ) + ->method( 'search' ) + ->with( self::MERCHANT_ID, $search_request ) + ->willReturn( $response ); + + $this->assertEquals( + [ + 'statuses' => [ + $wc_product_id_1 => [ + 'product_id' => $wc_product_id_1, + 'status' => MCStatus::APPROVED, + 'expiration_date' => $this->convert_shopping_content_date( $product_view_1->getExpirationDate() ), + ], + $wc_product_id_2 => [ + 'product_id' => $wc_product_id_2, + 'status' => MCStatus::DISAPPROVED, + 'expiration_date' => $this->convert_shopping_content_date( $product_view_2->getExpirationDate() ), + ], + ], + 'next_page_token' => null, + ], + $this->merchant_report->get_product_view_report() + ); + } + + public function test_get_product_view_report_with_exception() { + $this->shopping_client->reports->expects( $this->once() ) + ->method( 'search' ) + ->will( + $this->throwException( new GoogleException( 'Test exception' ) ) + ); + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( 'Unable to retrieve Product View Report.' ); + $this->merchant_report->get_product_view_report(); + } +} diff --git a/tests/Unit/MerchantCenter/MerchantStatusesTest.php b/tests/Unit/MerchantCenter/MerchantStatusesTest.php index 26eddad6e1..8671d4b664 100644 --- a/tests/Unit/MerchantCenter/MerchantStatusesTest.php +++ b/tests/Unit/MerchantCenter/MerchantStatusesTest.php @@ -10,6 +10,8 @@ use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService; use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses; use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface; +use Automattic\WooCommerce\GoogleListingsAndAds\Options\Transients; +use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface; use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper; use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductRepository; use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest; @@ -17,7 +19,10 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent; use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateMerchantProductStatuses; use Automattic\WooCommerce\GoogleListingsAndAds\Value\MCStatus; +use DateTime; +use DateInterval; use Exception; +use WC_Helper_Product; defined( 'ABSPATH' ) || exit; @@ -35,6 +40,12 @@ */ class MerchantStatusesTest extends UnitTest { + + /** + * Lifetime of the MC Status transient. + */ + protected const MC_STATUS_LIFETIME = 60; + private $merchant; private $merchant_issue_query; private $merchant_center_service; @@ -45,6 +56,7 @@ class MerchantStatusesTest extends UnitTest { private $product_helper; private $transients; private $update_merchant_product_statuses_job; + private $options; /** * Runs before each test is executed. @@ -60,6 +72,7 @@ public function setUp(): void { $this->product_helper = $this->createMock( ProductHelper::class ); $this->transients = $this->createMock( TransientsInterface::class ); $this->update_merchant_product_statuses_job = $this->createMock( UpdateMerchantProductStatuses::class ); + $this->options = $this->createMock( OptionsInterface::class ); $merchant_issue_table = $this->createMock( MerchantIssueTable::class ); @@ -76,6 +89,7 @@ public function setUp(): void { $this->merchant_statuses = new MerchantStatuses(); $this->merchant_statuses->set_container( $container ); + $this->merchant_statuses->set_options_object( $this->options ); } public function test_refresh_account_issues() { @@ -346,4 +360,147 @@ public function test_get_product_statistics_with_force_refresh() { $product_statistics['loading'] ); } + + public function test_update_product_stats() { + $product_1 = WC_Helper_Product::create_simple_product(); + $product_2 = WC_Helper_Product::create_simple_product(); + $product_3 = WC_Helper_Product::create_simple_product(); + $variable_product = WC_Helper_Product::create_variation_product(); + + $variations = $variable_product->get_available_variations(); + $variation_id_1 = $variations[0]['variation_id']; + $variation_id_2 = $variations[1]['variation_id']; + + add_filter( + 'woocommerce_gla_mc_status_lifetime', + function () { + return self::MC_STATUS_LIFETIME; + } + ); + + $this->product_repository->expects( $this->once() )->method( 'find_by_ids' )->with( + [ + $product_1->get_id(), + $product_2->get_id(), + $product_3->get_id(), + $variation_id_1, + $variation_id_2, + ] + )->willReturn( [ $product_1, $product_2, $product_3, wc_get_product( $variation_id_1 ) , wc_get_product( $variation_id_2 ) ] ); + + $this->options->expects( $this->exactly( 1 ) ) + ->method( 'get' ) + ->with( OptionsInterface::PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA ) + ->willReturn( null ); + + $this->options->expects( $this->once() ) + ->method( 'update' )->with( + OptionsInterface::PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA, + $this->callback( + function ( $value ) { + $this->assertEquals( + [ + + MCStatus::APPROVED => 2, + MCStatus::PARTIALLY_APPROVED => 1, + MCStatus::EXPIRING => 1, + MCStatus::DISAPPROVED => 0, + MCStatus::NOT_SYNCED => 0, + MCStatus::PENDING => 0, + ], + $value + ); + + return true; + } + ) + ); + + $product_statuses = [ + [ + 'product_id' => $product_1->get_id(), + 'status' => MCStatus::APPROVED, + 'expiration_date' => ( new DateTime() )->add( new DateInterval( 'P20D' ) ), + ], + [ + 'product_id' => $product_2->get_id(), + 'status' => MCStatus::PARTIALLY_APPROVED, + 'expiration_date' => ( new DateTime() )->add( new DateInterval( 'P20D' ) ), + ], + [ + 'product_id' => $product_3->get_id(), + 'status' => MCStatus::APPROVED, + 'expiration_date' => ( new DateTime() )->add( new DateInterval( 'P1D' ) ), // Expiring tomorrow + ], + // Variations are grouped by parent id. + [ + 'product_id' => $variation_id_1, + 'status' => MCStatus::APPROVED, + 'expiration_date' => ( new DateTime() )->add( new DateInterval( 'P20D' ) ), + ], + [ + 'product_id' => $variation_id_2, + 'status' => MCStatus::APPROVED, + 'expiration_date' => ( new DateTime() )->add( new DateInterval( 'P20D' ) ), + ], + + ]; + + $this->merchant_statuses->update_product_stats( + $product_statuses + ); + } + + public function test_handle_complete_mc_statuses_fetching() { + add_filter( + 'woocommerce_gla_mc_status_lifetime', + function () { + return self::MC_STATUS_LIFETIME; + } + ); + + $this->options->expects( $this->once() ) + ->method( 'get' )->with( OptionsInterface::PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA )->willReturn( + [ + MCStatus::APPROVED => 3, + MCStatus::PARTIALLY_APPROVED => 1, + MCStatus::EXPIRING => 0, + MCStatus::PENDING => 0, + MCStatus::DISAPPROVED => 1, + MCStatus::NOT_SYNCED => 0, + ] + ); + + $this->product_repository->expects( $this->once() )->method( 'find_all_product_ids' )->willReturn( [ 1, 2, 3, 4, 5, 6 ] ); + + $this->transients->expects( $this->once() ) + ->method( 'set' )->with( + Transients::MC_STATUSES, + $this->callback( + function ( $value ) { + $this->assertEquals( + [ + MCStatus::APPROVED => 3, + MCStatus::PARTIALLY_APPROVED => 1, + MCStatus::EXPIRING => 0, + MCStatus::PENDING => 0, + MCStatus::DISAPPROVED => 1, + MCStatus::NOT_SYNCED => 1, + ], + $value['statistics'] + ); + + $this->assertEquals( + false, + $value['loading'] + ); + + return true; + } + ), + self::MC_STATUS_LIFETIME + ); + + $this->merchant_statuses->handle_complete_mc_statuses_fetching(); + } }