diff --git a/includes/Feed/AbstractFeed.php b/includes/Feed/AbstractFeed.php index 18ed9a22b..f2f9f92bc 100644 --- a/includes/Feed/AbstractFeed.php +++ b/includes/Feed/AbstractFeed.php @@ -103,6 +103,10 @@ protected function add_hooks(): void { * @since 3.5.0 */ public function schedule_feed_generation(): void { + if ( $this->should_skip_feed() ) { + return; + } + $schedule_action_hook_name = self::GENERATE_FEED_ACTION . static::get_data_stream_name(); if ( ! as_next_scheduled_action( $schedule_action_hook_name ) ) { as_schedule_recurring_action( @@ -123,14 +127,32 @@ public function schedule_feed_generation(): void { * @since 3.5.0 */ public function regenerate_feed(): void { - // Maybe use new ( experimental ), feed generation framework. - if ( \WC_Facebookcommerce::instance()->get_integration()->is_new_style_feed_generation_enabled() ) { + if ( $this->should_skip_feed() ) { + return; + } + + if ( facebook_for_woocommerce()->get_integration()->is_new_style_feed_generation_enabled() ) { $this->feed_generator->queue_start(); } else { $this->feed_handler->generate_feed_file(); } } + /** + * The feed should be skipped if there isn't a Commerce Partner Integration ID set as the ID is required for + * calls to the GraphCommercePartnerIntegrationFileUpdatePost endpoint. + * Overwrite this function if your feed upload uses a different endpoint with different requirements. + * + * @since 3.5.0 + */ + public function should_skip_feed(): bool { + $connection_handler = facebook_for_woocommerce()->get_connection_handler(); + $cpi_id = $connection_handler->get_commerce_partner_integration_id(); + $cms_id = $connection_handler->get_commerce_merchant_settings_id(); + + return empty( $cpi_id ) || empty( $cms_id ); + } + /** * Trigger the upload flow * Once feed regenerated, trigger upload via create_upload API @@ -147,13 +169,22 @@ public function send_request_to_upload_feed(): void { ); try { - $cpi_id = get_option( 'wc_facebook_commerce_partner_integration_id', '' ); + $cpi_id = facebook_for_woocommerce()->get_connection_handler()->get_commerce_partner_integration_id(); facebook_for_woocommerce()-> get_api()-> create_common_data_feed_upload( $cpi_id, $data ); - } catch ( Exception $e ) { - // Log the error and continue. - \WC_Facebookcommerce_Utils::log( "{$name} feed: Failed to create feed upload request: " . $e->getMessage() ); + } catch ( \Exception $exception ) { + \WC_Facebookcommerce_Utils::logExceptionImmediatelyToMeta( + $exception, + [ + 'event' => 'feed_upload', + 'event_type' => 'send_request_to_upload_feed', + 'extra_data' => [ + 'feed_name' => $name, + 'data' => wp_json_encode( $data ), + ], + ] + ); } } @@ -244,16 +275,26 @@ public function handle_feed_data_request(): void { // fpassthru might be disabled in some hosts (like Flywheel). // phpcs:ignore if ( \WC_Facebookcommerce_Utils::is_fpassthru_disabled() || ! @fpassthru( $file ) ) { - \WC_Facebookcommerce_Utils::log( "{$name} feed: fpassthru is disabled: getting file contents" ); + \WC_Facebookcommerce_Utils::log( "{$name} feed: fpassthru is disabled: getting file contents." ); //phpcs:ignore $contents = @stream_get_contents( $file ); if ( ! $contents ) { - throw new PluginException( 'Could not get feed file contents.', 500 ); + throw new PluginException( "{$name} feed: Could not get feed file contents.", 500 ); } echo $contents; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } } catch ( \Exception $exception ) { - \WC_Facebookcommerce_Utils::log( "{$name} feed: Could not serve feed. " . $exception->getMessage() . ' (' . $exception->getCode() . ')' ); + \WC_Facebookcommerce_Utils::logExceptionImmediatelyToMeta( + $exception, + [ + 'event' => 'feed_upload', + 'event_type' => 'handle_feed_data_request', + 'extra_data' => [ + 'feed_name' => $name, + 'file_path' => $file_path, + ], + ] + ); status_header( $exception->getCode() ); } exit; diff --git a/includes/Feed/CsvFeedFileWriter.php b/includes/Feed/CsvFeedFileWriter.php index e97771ddc..ace09a2ea 100644 --- a/includes/Feed/CsvFeedFileWriter.php +++ b/includes/Feed/CsvFeedFileWriter.php @@ -109,8 +109,17 @@ public function write_feed_file( array $data ): void { // Step 3: Rename temporary feed file to final feed file. $this->promote_temp_file(); - } catch ( PluginException $e ) { - WC_Facebookcommerce_Utils::logExceptionImmediatelyToMeta( $e ); + } catch ( PluginException $exception ) { + \WC_Facebookcommerce_Utils::logExceptionImmediatelyToMeta( + $exception, + [ + 'event' => 'feed_upload', + 'event_type' => 'csv_write_feed_file', + 'extra_data' => [ + 'feed_name' => $this->feed_name, + ], + ] + ); // Close the temporary file if it is still open. if ( ! empty( $temp_feed_file ) && is_resource( $temp_feed_file ) ) { fclose( $temp_feed_file ); // phpcs:ignore diff --git a/includes/Feed/FeedUploadUtils.php b/includes/Feed/FeedUploadUtils.php index 2a83612a3..66220e33d 100644 --- a/includes/Feed/FeedUploadUtils.php +++ b/includes/Feed/FeedUploadUtils.php @@ -18,72 +18,97 @@ * @since 3.5.0 */ class FeedUploadUtils { - const VALUE_TYPE_PERCENTAGE = 'PERCENTAGE'; - const VALUE_TYPE_FIXED_AMOUNT = 'FIXED_AMOUNT'; - const TARGET_TYPE_SHIPPING = 'SHIPPING'; - const TARGET_TYPE_LINE_ITEM = 'LINE_ITEM'; - const TARGET_GRANULARITY_ORDER_LEVEL = 'ORDER_LEVEL'; - const TARGET_GRANULARITY_ITEM_LEVEL = 'ITEM_LEVEL'; - const TARGET_SELECTION_ENTIRE_CATALOG = 'ALL_CATALOG_PRODUCTS'; - const TARGET_SELECTION_SPECIFIC_PRODUCTS = 'SPECIFIC_PRODUCTS'; - const APPLICATION_TYPE_BUYER_APPLIED = 'BUYER_APPLIED'; - const PROMO_SYNC_LOGGING_FLOW_NAME = 'promotion_feed_sync'; + const VALUE_TYPE_PERCENTAGE = 'PERCENTAGE'; + const VALUE_TYPE_FIXED_AMOUNT = 'FIXED_AMOUNT'; + const TARGET_TYPE_SHIPPING = 'SHIPPING'; + const TARGET_TYPE_LINE_ITEM = 'LINE_ITEM'; + const TARGET_GRANULARITY_ORDER_LEVEL = 'ORDER_LEVEL'; + const TARGET_GRANULARITY_ITEM_LEVEL = 'ITEM_LEVEL'; + const TARGET_SELECTION_ENTIRE_CATALOG = 'ALL_CATALOG_PRODUCTS'; + const TARGET_SELECTION_SPECIFIC_PRODUCTS = 'SPECIFIC_PRODUCTS'; + const APPLICATION_TYPE_BUYER_APPLIED = 'BUYER_APPLIED'; + const PROMO_SYNC_LOGGING_FLOW_NAME = 'promotion_feed_sync'; + const RATINGS_AND_REVIEWS_SYNC_LOGGING_FLOW_NAME = 'ratings_and_reviews_feed_sync'; public static function get_ratings_and_reviews_data( array $query_args ): array { - $comments = get_comments( $query_args ); - $reviews_data = array(); + try { + $comments = get_comments( $query_args ); + $reviews_data = array(); - $store_name = get_bloginfo( 'name' ); - $store_id = get_option( 'wc_facebook_commerce_merchant_settings_id', '' ); - $store_urls = [ wc_get_page_permalink( 'shop' ) ]; + $store_name = get_bloginfo( 'name' ); + $store_id = facebook_for_woocommerce()->get_connection_handler()->get_commerce_merchant_settings_id(); + $store_urls = [ wc_get_page_permalink( 'shop' ) ]; - foreach ( $comments as $comment ) { - try { - $post_type = get_post_type( $comment->comment_post_ID ); - if ( 'product' !== $post_type ) { - continue; - } + foreach ( $comments as $comment ) { + try { + $post_type = get_post_type( $comment->comment_post_ID ); + if ( 'product' !== $post_type ) { + continue; + } - $rating = get_comment_meta( $comment->comment_ID, 'rating', true ); - if ( ! is_numeric( $rating ) ) { - continue; - } + $rating = get_comment_meta( $comment->comment_ID, 'rating', true ); + if ( ! is_numeric( $rating ) ) { + continue; + } - $reviewer_id = $comment->user_id; - // If reviewer_id is 0 then the reviewer is a logged-out user - $reviewer_is_anonymous = '0' === $reviewer_id ? 'true' : 'false'; + $reviewer_id = $comment->user_id; + // If reviewer_id is 0 then the reviewer is a logged-out user + $reviewer_is_anonymous = '0' === $reviewer_id ? 'true' : 'false'; - $product = wc_get_product( $comment->comment_post_ID ); - if ( null === $product ) { + $product = wc_get_product( $comment->comment_post_ID ); + if ( null === $product ) { + continue; + } + $product_name = $product->get_name(); + $product_url = $product->get_permalink(); + $product_skus = [ $product->get_sku() ]; + + $reviews_data[] = array( + 'aggregator' => 'woocommerce', + 'store.name' => $store_name, + 'store.id' => $store_id, + 'store.storeUrls' => "['" . implode( "','", $store_urls ) . "']", + 'review_id' => $comment->comment_ID, + 'rating' => intval( $rating ), + 'title' => null, + 'content' => $comment->comment_content, + 'created_at' => $comment->comment_date, + 'reviewer.name' => $comment->comment_author, + 'reviewer.reviewerID' => $reviewer_id, + 'reviewer.isAnonymous' => $reviewer_is_anonymous, + 'product.name' => $product_name, + 'product.url' => $product_url, + 'product.productIdentifiers.skus' => "['" . implode( "','", $product_skus ) . "']", + ); + } catch ( \Exception $e ) { + \WC_Facebookcommerce_Utils::logTelemetryToMeta( + 'Exception while trying to map product review data for feed', + array( + 'flow_name' => self::RATINGS_AND_REVIEWS_SYNC_LOGGING_FLOW_NAME, + 'flow_step' => 'map_ratings_and_reviews_data', + 'extra_data' => [ + 'exception_message' => $e->getMessage(), + ], + ) + ); continue; } - $product_name = $product->get_name(); - $product_url = $product->get_permalink(); - $product_skus = [ $product->get_sku() ]; - - $reviews_data[] = array( - 'aggregator' => 'woocommerce', - 'store.name' => $store_name, - 'store.id' => $store_id, - 'store.storeUrls' => "['" . implode( "','", $store_urls ) . "']", - 'review_id' => $comment->comment_ID, - 'rating' => intval( $rating ), - 'title' => null, - 'content' => $comment->comment_content, - 'created_at' => $comment->comment_date, - 'reviewer.name' => $comment->comment_author, - 'reviewer.reviewerID' => $reviewer_id, - 'reviewer.isAnonymous' => $reviewer_is_anonymous, - 'product.name' => $product_name, - 'product.url' => $product_url, - 'product.productIdentifiers.skus' => "['" . implode( "','", $product_skus ) . "']", - ); - } catch ( \Exception $e ) { - continue; } - } - return $reviews_data; + return $reviews_data; + } catch ( \Exception $exception ) { + \WC_Facebookcommerce_Utils::logExceptionImmediatelyToMeta( + $exception, + [ + 'event' => self::RATINGS_AND_REVIEWS_SYNC_LOGGING_FLOW_NAME, + 'event_type' => 'get_ratings_and_reviews_data', + 'extra_data' => [ + 'query_args' => wp_json_encode( $query_args ), + ], + ] + ); + throw $exception; + } } /** @@ -124,7 +149,7 @@ public static function get_coupons_data( array $query_args ): array { 'Unknown discount type encountered during feed processing', array( 'promotion_id' => $coupon_post->ID, - 'extra_data' => array( 'discount_type' => $woo_discount_type ), + 'extra_data' => [ 'discount_type' => $woo_discount_type ], 'flow_name' => self::PROMO_SYNC_LOGGING_FLOW_NAME, 'flow_step' => 'map_discount_type', ) diff --git a/includes/Handlers/Connection.php b/includes/Handlers/Connection.php index 95614ef23..8bd3b211b 100644 --- a/includes/Handlers/Connection.php +++ b/includes/Handlers/Connection.php @@ -99,7 +99,7 @@ class Connection { /** @var string the Commerce Partner Integration ID option name */ const OPTION_COMMERCE_PARTNER_INTEGRATION_ID = 'wc_facebook_commerce_partner_integration_id'; - + /** @var string|null the generated external merchant settings ID */ private $external_business_id; diff --git a/tests/Unit/Feed/AbstractFeedTest.php b/tests/Unit/Feed/AbstractFeedTest.php new file mode 100644 index 000000000..2c05385f7 --- /dev/null +++ b/tests/Unit/Feed/AbstractFeedTest.php @@ -0,0 +1,117 @@ +init( + $file_writer, + $feed_handler, + $feed_generator, + ); + } + + protected static function get_data_stream_name(): string { + return 'test'; + } + + protected static function get_feed_type(): string { + return 'TEST_FEED'; + } + + protected static function get_feed_gen_interval(): int { + return HOUR_IN_SECONDS; + } + + protected static function get_feed_gen_scheduling_interval(): string { + return Heartbeat::EVERY_5_MINUTES; + } +} + +class AbstractFeedTest extends WP_UnitTestCase { + /** + * The test feed class. + * + * @var AbstractFeed + * @since 3.5.0 + */ + protected AbstractFeed $feed; + + public function setUp(): void { + parent::setUp(); + $file_writer = $this->createMock( FeedFileWriter::class ); + $feed_handler = $this->createMock( AbstractFeedHandler::class ); + $feed_generator = $this->createMock( FeedGenerator::class ); + $this->feed = new TestFeed($file_writer, $feed_handler, $feed_generator); + } + + public function testShouldSkipFeed() { + update_option( 'wc_facebook_commerce_partner_integration_id', '1841465350002849' ); + update_option( 'wc_facebook_commerce_merchant_settings_id', '1352794439398752' ); + $this->assertFalse( $this->feed->should_skip_feed(), 'Feed should not be skipped when CPI ID and CMS ID are set.' ); + update_option( 'wc_facebook_commerce_partner_integration_id', '' ); + update_option( 'wc_facebook_commerce_merchant_settings_id', '1352794439398752' ); + $this->assertTrue( $this->feed->should_skip_feed(), 'Feed should be skipped when CPI ID is empty.' ); + update_option( 'wc_facebook_commerce_partner_integration_id', '1841465350002849' ); + update_option( 'wc_facebook_commerce_merchant_settings_id', '' ); + $this->assertTrue( $this->feed->should_skip_feed(), 'Feed should be skipped when CMS ID is empty.' ); + update_option( 'wc_facebook_commerce_partner_integration_id', '' ); + update_option( 'wc_facebook_commerce_merchant_settings_id', '' ); + $this->assertTrue( $this->feed->should_skip_feed(), 'Feed should be skipped when both CPI ID and CMS ID are empty.' ); + } + + public function testGetFeedSecret() { + $secret_option_name = 'wc_facebook_feed_url_secret_test'; + $this->assertEmpty(get_option($secret_option_name, ''), 'Secret should not be set yet.'); + $secret = $this->feed->get_feed_secret(); + $this->assertNotEmpty($secret, 'When secret is not set yet one should be generated.'); + $this->assertEquals($secret, get_option($secret_option_name, ''), 'Secret should be set.'); + } + + public function testGetDataStreamName() { + $reflection = new \ReflectionClass($this->feed); + $method = $reflection->getMethod('get_data_stream_name'); + $method->setAccessible(true); + + $data_stream_name = $method->invoke($this->feed); + $this->assertEquals('test', $data_stream_name, 'The data stream name should be "test".'); + } + + public function testGetFeedType() { + $reflection = new \ReflectionClass($this->feed); + $method = $reflection->getMethod('get_feed_type'); + $method->setAccessible(true); + + $feed_type = $method->invoke($this->feed); + $this->assertEquals('TEST_FEED', $feed_type, 'The feed type should be "TEST_FEED".'); + } + + public function testGetFeedGenInterval() { + $reflection = new \ReflectionClass($this->feed); + $method = $reflection->getMethod('get_feed_gen_interval'); + $method->setAccessible(true); + + $feed_gen_interval = $method->invoke($this->feed); + $this->assertEquals(HOUR_IN_SECONDS, $feed_gen_interval, 'The feed gen interval should be HOUR_IN_SECONDS.'); + } + + public function testGetFeedGenSchedulingInterval() { + $reflection = new \ReflectionClass($this->feed); + $method = $reflection->getMethod('get_feed_gen_scheduling_interval'); + $method->setAccessible(true); + + $feed_gen_scheduling_interval = $method->invoke($this->feed); + $this->assertEquals(Heartbeat::EVERY_5_MINUTES, $feed_gen_scheduling_interval, 'The feed gen scheduling interval should be Heartbeat::EVERY_5_MINUTES.'); + } +}