chromium/ios/chrome/browser/ui/price_notifications/price_notifications_price_tracking_mediator_unittest.mm

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "ios/chrome/browser/ui/price_notifications/price_notifications_price_tracking_mediator.h"

#import <UserNotifications/UserNotifications.h>

#import <string_view>

#import "base/apple/foundation_util.h"
#import "base/memory/raw_ptr.h"
#import "base/strings/sys_string_conversions.h"
#import "base/test/ios/wait_util.h"
#import "components/bookmarks/test/bookmark_test_helpers.h"
#import "components/commerce/core/mock_shopping_service.h"
#import "components/commerce/core/price_tracking_utils.h"
#import "components/commerce/core/test_utils.h"
#import "components/image_fetcher/core/cached_image_fetcher.h"
#import "components/image_fetcher/core/image_data_fetcher.h"
#import "components/power_bookmarks/core/power_bookmark_utils.h"
#import "ios/chrome/browser/bookmarks/model/bookmark_model_factory.h"
#import "ios/chrome/browser/commerce/model/shopping_service_factory.h"
#import "ios/chrome/browser/price_insights/coordinator/test_price_insights_consumer.h"
#import "ios/chrome/browser/price_insights/ui/price_insights_item.h"
#import "ios/chrome/browser/push_notification/model/push_notification_service.h"
#import "ios/chrome/browser/shared/model/browser/browser.h"
#import "ios/chrome/browser/shared/model/browser/browser_list.h"
#import "ios/chrome/browser/shared/model/browser/browser_list_factory.h"
#import "ios/chrome/browser/shared/model/browser/test/test_browser.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
#import "ios/chrome/browser/shared/model/profile/test/test_profile_manager_ios.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
#import "ios/chrome/browser/shared/ui/table_view/table_view_utils.h"
#import "ios/chrome/browser/ui/price_notifications/cells/price_notifications_table_view_item.h"
#import "ios/chrome/browser/ui/price_notifications/price_notifications_mutator.h"
#import "ios/chrome/browser/ui/price_notifications/price_notifications_table_view_controller.h"
#import "ios/chrome/browser/ui/price_notifications/test_price_notifications_consumer.h"
#import "ios/chrome/test/ios_chrome_scoped_testing_local_state.h"
#import "ios/public/provider/chrome/browser/push_notification/push_notification_api.h"
#import "ios/testing/scoped_block_swizzler.h"
#import "ios/web/public/test/fakes/fake_navigation_manager.h"
#import "ios/web/public/test/fakes/fake_web_state.h"
#import "ios/web/public/test/web_task_environment.h"
#import "ios/web/public/web_state.h"
#import "testing/platform_test.h"
#import "third_party/ocmock/OCMock/OCMock.h"
#import "third_party/ocmock/gtest_support.h"

namespace {

const char kTestUrl[] = "https://www.merchant.com/price_drop_product";
const char kTestUrlVariant[] =
    "https://www.merchant.com/price_drop_product?variant=1";
const char kBookmarkTitle[] = "My product title";
uint64_t kClusterId = 12345L;

PriceInsightsItem* GetPriceInsightsItem() {
  PriceInsightsItem* item = [[PriceInsightsItem alloc] init];
  item.title = base::SysUTF8ToNSString(kBookmarkTitle);
  item.productURL = GURL(kTestUrl);
  item.buyingOptionsURL = GURL(kTestUrl);
  return item;
}

void TrackBookmark(commerce::ShoppingService* shopping_service,
                   bookmarks::BookmarkModel* bookmark_model,
                   const bookmarks::BookmarkNode* product) {
  base::RunLoop run_loop;
  SetPriceTrackingStateForBookmark(
      shopping_service, bookmark_model, product, true,
      base::BindOnce(
          [](base::RunLoop* run_loop, bool success) {
            EXPECT_TRUE(success);
            run_loop->Quit();
          },
          &run_loop));
  run_loop.Run();
}

const bookmarks::BookmarkNode* PrepareSubscription(
    commerce::MockShoppingService* shopping_service,
    bookmarks::BookmarkModel* bookmark_model,
    BOOL unsubscribe_callback) {
  std::string_view title = kBookmarkTitle;
  const bookmarks::BookmarkNode* product =
      commerce::AddProductBookmark(bookmark_model, base::UTF8ToUTF16(title),
                                   GURL(kTestUrl), kClusterId, true);
  const bookmarks::BookmarkNode* default_folder =
      bookmark_model->account_mobile_node();
  bookmark_model->AddURL(default_folder, default_folder->children().size(),
                         base::UTF8ToUTF16(title), GURL(kTestUrl));
  shopping_service->SetSubscribeCallbackValue(true);
  shopping_service->SetIsSubscribedCallbackValue(true);
  shopping_service->SetUnsubscribeCallbackValue(unsubscribe_callback);
  TrackBookmark(shopping_service, bookmark_model, product);

  commerce::ProductInfo product_info;
  product_info.title = kBookmarkTitle;
  std::optional<commerce::ProductInfo> optional_product_info;
  optional_product_info.emplace(product_info);
  shopping_service->SetResponseForGetProductInfoForUrl(optional_product_info);

  std::vector<commerce::CommerceSubscription> subscriptions;
  std::unique_ptr<power_bookmarks::PowerBookmarkMeta> meta =
      power_bookmarks::GetNodePowerBookmarkMeta(bookmark_model, product);
  commerce::CommerceSubscription sub(
      commerce::SubscriptionType::kPriceTrack,
      commerce::IdentifierType::kProductClusterId,
      base::NumberToString(meta->shopping_specifics().product_cluster_id()),
      commerce::ManagementType::kUserManaged);
  subscriptions.push_back(sub);
  shopping_service->SetGetAllSubscriptionsCallbackValue(subscriptions);
  return product;
}

}  // namespace

class PriceNotificationsPriceTrackingMediatorTest : public PlatformTest {
 public:
  PriceNotificationsPriceTrackingMediatorTest() {
    TestChromeBrowserState::Builder builder;
    builder.AddTestingFactory(ios::BookmarkModelFactory::GetInstance(),
                              ios::BookmarkModelFactory::GetDefaultFactory());
    builder.AddTestingFactory(
        commerce::ShoppingServiceFactory::GetInstance(),
        base::BindRepeating(
            [](web::BrowserState*) -> std::unique_ptr<KeyedService> {
              return commerce::MockShoppingService::Build();
            }));
    TestChromeBrowserState* test_chrome_browser_state =
        profile_manager_.AddProfileWithBuilder(std::move(builder));

    browser_list_ =
        BrowserListFactory::GetForBrowserState(test_chrome_browser_state);
    browser_ = std::make_unique<TestBrowser>(test_chrome_browser_state);
    browser_list_->AddBrowser(browser_.get());
    web_state_ = std::make_unique<web::FakeWebState>();
    std::unique_ptr<web::FakeNavigationManager> navigation_manager =
        std::make_unique<web::FakeNavigationManager>();
    navigation_manager->AddItem(GURL(kTestUrl), ui::PAGE_TRANSITION_LINK);
    navigation_manager->SetLastCommittedItem(
        navigation_manager->GetItemAtIndex(0));
    web_state_->SetNavigationManager(std::move(navigation_manager));
    web_state_->SetBrowserState(test_chrome_browser_state);
    web_state_->SetNavigationItemCount(1);
    web_state_->SetCurrentURL(GURL(kTestUrl));
    image_fetcher_ = std::make_unique<image_fetcher::ImageDataFetcher>(
        test_chrome_browser_state->GetSharedURLLoaderFactory());

    bookmark_model_ = ios::BookmarkModelFactory::GetForBrowserState(
        test_chrome_browser_state);
    bookmarks::test::WaitForBookmarkModelToLoad(bookmark_model_);
    bookmark_model_->CreateAccountPermanentFolders();

    shopping_service_ = static_cast<commerce::MockShoppingService*>(
        commerce::ShoppingServiceFactory::GetForBrowserState(
            test_chrome_browser_state));
    shopping_service_->SetupPermissiveMock();
    push_notification_service_ = ios::provider::CreatePushNotificationService();
    mediator_ = [[PriceNotificationsPriceTrackingMediator alloc]
        initWithShoppingService:(commerce::ShoppingService*)shopping_service_
                  bookmarkModel:bookmark_model_
                   imageFetcher:std::move(image_fetcher_)
                       webState:web_state_.get()->GetWeakPtr()
        pushNotificationService:(PushNotificationService*)
                                    push_notification_service_.get()];
  }

 protected:
  // Sets up a mock notification center, so notification requests can be
  // tested.
  void SetupMockNotificationCenter() {
    mock_notification_center_ = OCMClassMock([UNUserNotificationCenter class]);
    // Swizzle in the mock notification center.
    UNUserNotificationCenter* (^swizzle_block)() =
        ^UNUserNotificationCenter*() {
          return mock_notification_center_;
        };
    notification_center_swizzler_ = std::make_unique<ScopedBlockSwizzler>(
        [UNUserNotificationCenter class], @selector(currentNotificationCenter),
        swizzle_block);
  }

  web::WebTaskEnvironment task_environment_;
  IOSChromeScopedTestingLocalState scoped_testing_local_state_;
  TestProfileManagerIOS profile_manager_;
  std::unique_ptr<Browser> browser_;
  PriceNotificationsPriceTrackingMediator* mediator_;
  std::unique_ptr<web::FakeWebState> web_state_;
  raw_ptr<commerce::MockShoppingService> shopping_service_;
  raw_ptr<bookmarks::BookmarkModel> bookmark_model_;
  raw_ptr<BrowserList> browser_list_;
  std::unique_ptr<image_fetcher::ImageDataFetcher> image_fetcher_;
  std::unique_ptr<PushNotificationService> push_notification_service_;
  std::unique_ptr<ScopedBlockSwizzler> notification_center_swizzler_;
  id mock_notification_center_;
  TestPriceNotificationsConsumer* consumer_ =
      [[TestPriceNotificationsConsumer alloc] init];
  TestPriceInsightsConsumer* price_insights_consumer_ =
      [[TestPriceInsightsConsumer alloc] init];
};

TEST_F(PriceNotificationsPriceTrackingMediatorTest,
       TrackableItemIsEmptyWhenUserIsViewingProductWebpageAndProduct) {
  PrepareSubscription(shopping_service_, bookmark_model_, true);
  mediator_.consumer = consumer_;

  ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
      base::test::ios::kWaitForActionTimeout, ^bool {
        base::RunLoop().RunUntilIdle();
        return consumer_.didExecuteAction;
      }));

  EXPECT_EQ(consumer_.trackableItem, nil);
  EXPECT_EQ(consumer_.isCurrentlyTrackingVisibleProduct, YES);
}

TEST_F(
    PriceNotificationsPriceTrackingMediatorTest,
    TrackableItemExistsWhenUserUntracksProductFromWebpageIsCurrentlyViewing) {
  commerce::ProductInfo product_info;
  product_info.title = kBookmarkTitle;
  product_info.product_cluster_id.emplace(12345L);
  std::optional<commerce::ProductInfo> optional_product_info;
  optional_product_info.emplace(product_info);
  shopping_service_->SetResponseForGetProductInfoForUrl(optional_product_info);
  mediator_.consumer = consumer_;

  ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
      base::test::ios::kWaitForActionTimeout, ^bool {
        base::RunLoop().RunUntilIdle();
        return consumer_.didExecuteAction;
      }));

  consumer_.didExecuteAction = NO;
  const bookmarks::BookmarkNode* default_folder =
      bookmark_model_->account_mobile_node();
  bookmark_model_->AddURL(default_folder, default_folder->children().size(),
                          base::UTF8ToUTF16(product_info.title),
                          GURL(kTestUrl));
  shopping_service_->SetUnsubscribeCallbackValue(true);

  PriceNotificationsTableViewItem* product =
      [[PriceNotificationsTableViewItem alloc] init];
  product.title = base::SysUTF8ToNSString(kBookmarkTitle);
  product.entryURL = GURL(kTestUrl);
  id<PriceNotificationsMutator> mutator = mediator_;
  [mutator stopTrackingItem:product];
  ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
      base::test::ios::kWaitForActionTimeout, ^bool {
        base::RunLoop().RunUntilIdle();
        return consumer_.didExecuteAction;
      }));

  EXPECT_EQ(consumer_.trackableItem.title, product.title);
}

TEST_F(PriceNotificationsPriceTrackingMediatorTest,
       SuccessfullyTrackedProductURLFromPriceInsights) {
  SetupMockNotificationCenter();
  id settings = OCMClassMock([UNNotificationSettings class]);
  OCMStub([mock_notification_center_
      getNotificationSettingsWithCompletionHandler:
          ([OCMArg invokeBlockWithArgs:settings, nil])]);
  OCMStub([settings authorizationStatus])
      .andReturn(UNAuthorizationStatusAuthorized);

  commerce::ProductInfo product_info;
  product_info.title = kBookmarkTitle;
  product_info.product_cluster_id = std::make_optional(kClusterId);
  std::optional<commerce::ProductInfo> optional_product_info;
  optional_product_info.emplace(product_info);
  shopping_service_->SetResponseForGetProductInfoForUrl(optional_product_info);

  price_insights_consumer_.didPriceTrack = NO;
  mediator_.priceInsightsConsumer = price_insights_consumer_;
  [mediator_ tryPriceInsightsTrackItem:GetPriceInsightsItem()];

  ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
      base::test::ios::kWaitForActionTimeout, ^bool {
        base::RunLoop().RunUntilIdle();
        return price_insights_consumer_.didPriceTrack;
      }));

  EXPECT_OCMOCK_VERIFY(mock_notification_center_);
}

TEST_F(PriceNotificationsPriceTrackingMediatorTest,
       PresentAlertWhenTrackingIsUnsuccessfulFromPriceInsights) {
  SetupMockNotificationCenter();
  id settings = OCMClassMock([UNNotificationSettings class]);
  OCMStub([mock_notification_center_
      getNotificationSettingsWithCompletionHandler:
          ([OCMArg invokeBlockWithArgs:settings, nil])]);
  OCMStub([settings authorizationStatus])
      .andReturn(UNAuthorizationStatusAuthorized);

  commerce::ProductInfo product_info;
  product_info.title = kBookmarkTitle;
  product_info.product_cluster_id = std::make_optional(kClusterId);
  std::optional<commerce::ProductInfo> optional_product_info;
  optional_product_info.emplace(product_info);
  shopping_service_->SetResponseForGetProductInfoForUrl(optional_product_info);
  shopping_service_->SetSubscribeCallbackValue(false);

  price_insights_consumer_.didPresentStartPriceTrackingErrorAlertForItem = NO;
  mediator_.priceInsightsConsumer = price_insights_consumer_;
  [mediator_ tryPriceInsightsTrackItem:GetPriceInsightsItem()];

  ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
      base::test::ios::kWaitForActionTimeout, ^bool {
        base::RunLoop().RunUntilIdle();
        return price_insights_consumer_
            .didPresentStartPriceTrackingErrorAlertForItem;
      }));

  EXPECT_OCMOCK_VERIFY(mock_notification_center_);
}

TEST_F(PriceNotificationsPriceTrackingMediatorTest,
       SuccessfullyUntrackedProductURLFromPriceInsights) {
  commerce::ProductInfo product_info;
  product_info.title = kBookmarkTitle;
  product_info.product_cluster_id = std::make_optional(kClusterId);
  std::optional<commerce::ProductInfo> optional_product_info;
  optional_product_info.emplace(product_info);

  const bookmarks::BookmarkNode* default_folder =
      bookmark_model_->account_mobile_node();
  bookmark_model_->AddURL(default_folder, default_folder->children().size(),
                          base::UTF8ToUTF16(product_info.title),
                          GURL(kTestUrl));
  shopping_service_->SetUnsubscribeCallbackValue(true);
  shopping_service_->SetResponseForGetProductInfoForUrl(optional_product_info);

  price_insights_consumer_.didPriceUntrack = NO;
  mediator_.priceInsightsConsumer = price_insights_consumer_;
  [mediator_ priceInsightsStopTrackingItem:GetPriceInsightsItem()];

  ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
      base::test::ios::kWaitForActionTimeout, ^bool {
        base::RunLoop().RunUntilIdle();
        return price_insights_consumer_.didPriceUntrack;
      }));
}

TEST_F(PriceNotificationsPriceTrackingMediatorTest,
       PresentAlertWhenUntrackingIsUnsuccessfulFromPriceInsights) {
  commerce::ProductInfo product_info;
  product_info.title = kBookmarkTitle;
  product_info.product_cluster_id = std::make_optional(kClusterId);
  std::optional<commerce::ProductInfo> optional_product_info;
  optional_product_info.emplace(product_info);

  const bookmarks::BookmarkNode* default_folder =
      bookmark_model_->account_mobile_node();
  bookmark_model_->AddURL(default_folder, default_folder->children().size(),
                          base::UTF8ToUTF16(product_info.title),
                          GURL(kTestUrl));
  shopping_service_->SetUnsubscribeCallbackValue(false);
  shopping_service_->SetResponseForGetProductInfoForUrl(optional_product_info);

  price_insights_consumer_.didPresentStopPriceTrackingErrorAlertForItem = NO;
  mediator_.priceInsightsConsumer = price_insights_consumer_;
  [mediator_ priceInsightsStopTrackingItem:GetPriceInsightsItem()];

  ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
      base::test::ios::kWaitForActionTimeout, ^bool {
        base::RunLoop().RunUntilIdle();
        return price_insights_consumer_
            .didPresentStopPriceTrackingErrorAlertForItem;
      }));
}

TEST_F(PriceNotificationsPriceTrackingMediatorTest,
       NavigateToWebPageUponUserRequestsFromPriceInsights) {
  price_insights_consumer_.didNavigateToWebpage = NO;
  mediator_.priceInsightsConsumer = price_insights_consumer_;
  [mediator_ priceInsightsNavigateToWebpageForItem:GetPriceInsightsItem()];

  ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
      base::test::ios::kWaitForActionTimeout, ^bool {
        base::RunLoop().RunUntilIdle();
        return price_insights_consumer_.didNavigateToWebpage;
      }));
}

TEST_F(PriceNotificationsPriceTrackingMediatorTest,
       SuccessfullyUntrackedProductFromPriceInsightsThroughVariantURL) {
  commerce::ProductInfo product_info;
  product_info.title = kBookmarkTitle;
  product_info.product_cluster_id = std::make_optional(kClusterId);
  std::optional<commerce::ProductInfo> optional_product_info;
  optional_product_info.emplace(product_info);

  const bookmarks::BookmarkNode* product = commerce::AddProductBookmark(
      bookmark_model_, base::UTF8ToUTF16(product_info.title), GURL(kTestUrl),
      kClusterId, true);
  const bookmarks::BookmarkNode* default_folder =
      bookmark_model_->account_mobile_node();
  bookmark_model_->AddURL(default_folder, default_folder->children().size(),
                          base::UTF8ToUTF16(product_info.title),
                          GURL(kTestUrl));
  shopping_service_->SetSubscribeCallbackValue(true);
  shopping_service_->SetIsSubscribedCallbackValue(true);
  TrackBookmark(shopping_service_, bookmark_model_, product);

  // Call to ensure backend calls to Unsubscribe are successful.
  shopping_service_->SetUnsubscribeCallbackValue(true);
  shopping_service_->SetResponseForGetProductInfoForUrl(optional_product_info);

  price_insights_consumer_.didPriceUntrack = NO;
  mediator_.priceInsightsConsumer = price_insights_consumer_;
  PriceInsightsItem* price_insights = GetPriceInsightsItem();
  price_insights.productURL = GURL(kTestUrlVariant);
  price_insights.clusterId = kClusterId;
  [mediator_ priceInsightsStopTrackingItem:price_insights];

  ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
      base::test::ios::kWaitForActionTimeout, ^bool {
        base::RunLoop().RunUntilIdle();
        return price_insights_consumer_.didPriceUntrack;
      }));
}

TEST_F(PriceNotificationsPriceTrackingMediatorTest,
       PresentNotificationAlertWhenNotificationAuthorizationDenied) {
  SetupMockNotificationCenter();
  id settings = OCMClassMock([UNNotificationSettings class]);
  OCMStub([mock_notification_center_
      getNotificationSettingsWithCompletionHandler:
          ([OCMArg invokeBlockWithArgs:settings, nil])]);
  OCMStub([settings authorizationStatus])
      .andReturn(UNAuthorizationStatusDenied);

  commerce::ProductInfo product_info;
  product_info.title = kBookmarkTitle;
  product_info.product_cluster_id = std::make_optional(kClusterId);
  std::optional<commerce::ProductInfo> optional_product_info;
  optional_product_info.emplace(product_info);
  shopping_service_->SetResponseForGetProductInfoForUrl(optional_product_info);

  price_insights_consumer_.didPriceTrack = NO;
  price_insights_consumer_.didPresentPushNotificationPermissionAlertForItem =
      NO;
  mediator_.priceInsightsConsumer = price_insights_consumer_;
  [mediator_ tryPriceInsightsTrackItem:GetPriceInsightsItem()];

  ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
      base::test::ios::kWaitForActionTimeout, ^bool {
        base::RunLoop().RunUntilIdle();
        return price_insights_consumer_
            .didPresentPushNotificationPermissionAlertForItem;
      }));

  ASSERT_FALSE(base::test::ios::WaitUntilConditionOrTimeout(
      base::test::ios::kWaitForActionTimeout, ^bool {
        base::RunLoop().RunUntilIdle();
        return price_insights_consumer_.didPriceTrack;
      }));

  EXPECT_OCMOCK_VERIFY(mock_notification_center_);
}

TEST_F(PriceNotificationsPriceTrackingMediatorTest,
       NoTrackWhenNotificationAuthorizationUndetermined) {
  SetupMockNotificationCenter();
  id settings = OCMClassMock([UNNotificationSettings class]);
  OCMStub([mock_notification_center_
      getNotificationSettingsWithCompletionHandler:
          ([OCMArg invokeBlockWithArgs:settings, nil])]);
  OCMStub([settings authorizationStatus])
      .andReturn(UNAuthorizationStatusNotDetermined);

  commerce::ProductInfo product_info;
  product_info.title = kBookmarkTitle;
  product_info.product_cluster_id = std::make_optional(kClusterId);
  std::optional<commerce::ProductInfo> optional_product_info;
  optional_product_info.emplace(product_info);
  shopping_service_->SetResponseForGetProductInfoForUrl(optional_product_info);

  price_insights_consumer_.didPriceTrack = NO;
  price_insights_consumer_.didPresentPushNotificationPermissionAlertForItem =
      NO;
  mediator_.priceInsightsConsumer = price_insights_consumer_;
  [mediator_ tryPriceInsightsTrackItem:GetPriceInsightsItem()];

  ASSERT_FALSE(base::test::ios::WaitUntilConditionOrTimeout(
      base::test::ios::kWaitForActionTimeout, ^bool {
        base::RunLoop().RunUntilIdle();
        return price_insights_consumer_.didPriceTrack;
      }));

  EXPECT_OCMOCK_VERIFY(mock_notification_center_);
}