chromium/chrome/browser/apps/app_discovery_service/almanac_fetcher_unittest.cc

// 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.

#include "chrome/browser/apps/app_discovery_service/almanac_fetcher.h"

#include "base/callback_list.h"
#include "base/path_service.h"
#include "base/task/single_thread_task_runner.h"
#include "base/task/task_runner.h"
#include "base/test/bind.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/test_future.h"
#include "base/test/test_proto_loader.h"
#include "base/time/time.h"
#include "chrome/browser/apps/almanac_api_client/mock_almanac_icon_cache.h"
#include "chrome/browser/apps/app_discovery_service/almanac_api/launcher_app.pb.h"
#include "chrome/browser/apps/app_discovery_service/app_discovery_service.h"
#include "chrome/browser/apps/app_discovery_service/app_discovery_util.h"
#include "chrome/browser/apps/app_discovery_service/game_extras.h"
#include "chrome/browser/apps/app_discovery_service/launcher_app_almanac_endpoint.h"
#include "chrome/browser/web_applications/test/web_app_install_test_utils.h"
#include "chrome/test/base/testing_profile.h"
#include "chromeos/ash/components/system/fake_statistics_provider.h"
#include "chromeos/constants/chromeos_features.h"
#include "content/public/test/browser_task_environment.h"
#include "services/network/public/cpp/weak_wrapper_shared_url_loader_factory.h"
#include "services/network/test/test_url_loader_factory.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "ui/base/resource/resource_bundle.h"
#include "ui/gfx/image/image.h"
#include "ui/gfx/image/image_unittest_util.h"
#include "ui/resources/grit/ui_resources.h"

namespace apps {
namespace {
using testing::_;
using ::testing::Invoke;

constexpr char kOneApp[] =
    R"pb(app_groups: {
           uuid: "cf2890ac-486f-11ee-be56-0242ac120002"
           name: "app_name"
           badge_text: "GeForce NOW"
           action_link: "https://game-deeplink.com/cf2be56486f11ee"
           icons: {
             url: "http://icon/"
             width_in_pixels: 20
             is_masking_allowed: true
           }
         })pb";
constexpr char kTwoValidApps[] =
    R"pb(app_groups: {
           uuid: "e42c6c70-7732-437f-b2e7-0d17036b8cc1"
           name: "app_name1"
           badge_text: "GeForce NOW"
           action_link: "https://game-deeplink.com/jrioj324j2095245234320o"
           icons: {
             url: "http://icon1/"
             width_in_pixels: 20
             is_masking_allowed: true
           }
         }
         app_groups: {
           uuid: "d8eb7470-9d43-472c-aa49-125f5c3111d4"
           name: "app_name2"
           badge_text: "Play"
           action_link: "https://game-deeplink.com/reijarowaiore131983u12jkljs893"
           icons: {
             url: "http://icon2/"
             width_in_pixels: 30
             is_masking_allowed: false
           }
         }
         # The invalid games below are filtered out.
         app_groups: {
           uuid: "invalid-game1-id"
           name: ""
           action_link: "https://invalid-game1-deeplink.com/"
           icons: {
             url: "http://icon3/"
             width_in_pixels: 30
             is_masking_allowed: false
           }
         }
         app_groups: {
           uuid: "invalid-game2-id"
           name: "invalid-game2"
           action_link: ""
           icons: {
             url: "http://icon3/"
             width_in_pixels: 30
             is_masking_allowed: false
           }
         }
         app_groups: {
           uuid: "invalid-game3-id"
           name: "invalid-game3"
           action_link: "https://invalid-game3-deeplink.com/"
         })pb";

// The path is equivalent to $root_gen_dir, where the protos are generated.
base::FilePath GetTestDataRoot() {
  return base::PathService::CheckedGet(base::DIR_GEN_TEST_DATA_ROOT);
}

void SetServerResponse(network::TestURLLoaderFactory& url_loader_factory,
                       base::TestProtoLoader* proto_loader,
                       const std::string& text_proto,
                       net::HttpStatusCode status = net::HTTP_OK) {
  std::string serialized_message;
  proto_loader->ParseFromText(text_proto, serialized_message);
  url_loader_factory.AddResponse(
      launcher_app_almanac_endpoint::GetServerUrl().spec(), serialized_message,
      status);
}

gfx::Image& GetTestImage(int resource_id) {
  return ui::ResourceBundle::GetSharedInstance().GetNativeImageNamed(
      resource_id);
}

class AlmanacFetcherTest : public testing::Test {
 public:
  AlmanacFetcherTest() {
    feature_list_.InitWithFeatures({chromeos::features::kCloudGamingDevice},
                                   {});
    launcher_app_descriptor_ = GetTestDataRoot().Append(FILE_PATH_LITERAL(
        "chrome/browser/apps/app_discovery_service/almanac_api/"
        "launcher_app.descriptor"));
  }

  void SetUp() override {
    testing::Test::SetUp();
    TestingProfile::Builder profile_builder;
    profile_builder.SetSharedURLLoaderFactory(
        url_loader_factory_.GetSafeWeakWrapper());
    profile_ = profile_builder.Build();
    web_app::test::AwaitStartWebAppProviderAndSubsystems(profile());
    auto icon_cache = std::make_unique<MockAlmanacIconCache>();
    icon_cache_ = icon_cache.get();
    AlmanacFetcher::SetSkipApiKeyCheckForTesting(true);
    almanac_fetcher_ =
        std::make_unique<AlmanacFetcher>(profile(), std::move(icon_cache));
    proto_loader_ = std::make_unique<base::TestProtoLoader>(
        launcher_app_descriptor_, "apps.proto.LauncherAppResponse");
    SetServerResponse(url_loader_factory_, proto_loader(), kTwoValidApps);
  }

  void TearDown() override { icon_cache_ = nullptr; }

  TestingProfile* profile() { return profile_.get(); }
  AlmanacFetcher* almanac_fetcher() { return almanac_fetcher_.get(); }
  base::TestProtoLoader* proto_loader() { return proto_loader_.get(); }

  // The path of the descriptor file for the launcher app proto.
  base::FilePath launcher_app_descriptor_;

  network::TestURLLoaderFactory url_loader_factory_;

  raw_ptr<MockAlmanacIconCache> icon_cache_;

 private:
  // BrowserTaskEnvironment has to be the first member or test will break.
  content::BrowserTaskEnvironment task_environment_;
  std::unique_ptr<TestingProfile> profile_;
  std::unique_ptr<AlmanacFetcher> almanac_fetcher_;
  std::unique_ptr<base::TestProtoLoader> proto_loader_;
  base::test::ScopedFeatureList feature_list_;
  ash::system::ScopedFakeStatisticsProvider fake_statistics_provider_;
};

TEST_F(AlmanacFetcherTest, RegisterForUpdatesTwoApps) {
  base::Time before_download = almanac_fetcher()->GetLastAppsUpdateTime();
  base::test::TestFuture<const std::vector<Result>&> waiter;
  base::CallbackListSubscription subscription =
      almanac_fetcher()->RegisterForAppUpdates(waiter.GetRepeatingCallback());
  std::vector<Result> results = waiter.Take();
  ASSERT_EQ(results.size(), 2u);
  EXPECT_EQ(results[0].GetAppSource(), AppSource::kGames);
  EXPECT_EQ(results[0].GetIconId(), "http://icon1/");
  EXPECT_EQ(results[0].GetAppTitle(), u"app_name1");
  ASSERT_TRUE(results[0].GetSourceExtras());
  auto* game_extras = results[0].GetSourceExtras()->AsGameExtras();
  ASSERT_TRUE(game_extras);
  EXPECT_EQ(game_extras->GetSource(), u"GeForce NOW");
  EXPECT_EQ(game_extras->GetDeeplinkUrl(),
            GURL("https://game-deeplink.com/jrioj324j2095245234320o"));
  EXPECT_TRUE(game_extras->GetIsIconMaskingAllowed());

  EXPECT_EQ(results[1].GetAppSource(), AppSource::kGames);
  EXPECT_EQ(results[1].GetIconId(), "http://icon2/");
  EXPECT_EQ(results[1].GetAppTitle(), u"app_name2");
  EXPECT_TRUE(results[1].GetSourceExtras());
  game_extras = results[1].GetSourceExtras()->AsGameExtras();
  ASSERT_TRUE(game_extras);
  EXPECT_EQ(game_extras->GetSource(), u"Play");
  EXPECT_EQ(game_extras->GetDeeplinkUrl(),
            GURL("https://game-deeplink.com/"
                 "reijarowaiore131983u12jkljs893"));
  EXPECT_FALSE(game_extras->GetIsIconMaskingAllowed());
  EXPECT_GT(almanac_fetcher()->GetLastAppsUpdateTime(), before_download);
}

TEST_F(AlmanacFetcherTest, RegisterForUpdatesNoApps) {
  base::Time before_download = almanac_fetcher()->GetLastAppsUpdateTime();
  proto::LauncherAppResponse proto;
  url_loader_factory_.AddResponse(
      launcher_app_almanac_endpoint::GetServerUrl().spec(),
      proto.SerializeAsString());
  base::test::TestFuture<const std::vector<Result>&> waiter;
  base::CallbackListSubscription subscription =
      almanac_fetcher()->RegisterForAppUpdates(waiter.GetRepeatingCallback());
  EXPECT_EQ(waiter.Take().size(), 0u);
  EXPECT_GT(almanac_fetcher()->GetLastAppsUpdateTime(), before_download);
}

TEST_F(AlmanacFetcherTest, RegisterForUpdatesAfterUpdate) {
  base::test::TestFuture<const std::vector<Result>&> waiter;
  base::CallbackListSubscription subscription =
      almanac_fetcher()->RegisterForAppUpdates(waiter.GetRepeatingCallback());
  std::vector<Result> results = waiter.Take();
  ASSERT_EQ(results.size(), 2u);
  EXPECT_EQ(results[0].GetAppTitle(), u"app_name1");
  EXPECT_EQ(results[1].GetAppTitle(), u"app_name2");

  // Confirm a new subscriber also gets notified as the apps are available.
  base::test::TestFuture<const std::vector<Result>&> waiter2;
  subscription =
      almanac_fetcher()->RegisterForAppUpdates(waiter2.GetRepeatingCallback());
  EXPECT_EQ(waiter2.Take().size(), 2u);
}

TEST_F(AlmanacFetcherTest, RegisterForUpdatesReadFromDisk) {
  base::Time before_download = almanac_fetcher()->GetLastAppsUpdateTime();
  base::test::TestFuture<const std::vector<Result>&> waiter;
  base::CallbackListSubscription subscription =
      almanac_fetcher()->RegisterForAppUpdates(waiter.GetRepeatingCallback());
  std::vector<Result> results = waiter.Take();
  ASSERT_EQ(results.size(), 2u);
  EXPECT_EQ(results[0].GetAppTitle(), u"app_name1");
  EXPECT_EQ(results[1].GetAppTitle(), u"app_name2");

  base::Time after_download = almanac_fetcher()->GetLastAppsUpdateTime();
  EXPECT_GT(after_download, before_download);

  // Read from disk as we've just successfully finished a download.
  AlmanacFetcher almanac_fetcher2(profile(), nullptr);
  base::test::TestFuture<const std::vector<Result>&> waiter2;
  base::CallbackListSubscription subscription2 =
      almanac_fetcher2.RegisterForAppUpdates(waiter2.GetRepeatingCallback());
  EXPECT_EQ(waiter2.Take().size(), 2u);
  EXPECT_EQ(almanac_fetcher()->GetLastAppsUpdateTime(), after_download);
}

TEST_F(AlmanacFetcherTest, RegisterForUpdatesServerCallFails) {
  base::Time before_download = almanac_fetcher()->GetLastAppsUpdateTime();
  base::test::TestFuture<const std::vector<Result>&> waiter;
  base::CallbackListSubscription subscription =
      almanac_fetcher()->RegisterForAppUpdates(waiter.GetRepeatingCallback());
  std::vector<Result> results = waiter.Take();
  ASSERT_EQ(results.size(), 2u);
  EXPECT_EQ(results[0].GetAppTitle(), u"app_name1");
  EXPECT_EQ(results[1].GetAppTitle(), u"app_name2");
  EXPECT_GT(almanac_fetcher()->GetLastAppsUpdateTime(), before_download);

  // Re-set to initiate a new login.
  almanac_fetcher()->SetLastAppsUpdateTime(before_download);
  SetServerResponse(url_loader_factory_, proto_loader(), kOneApp,
                    net::HTTP_INTERNAL_SERVER_ERROR);
  AlmanacFetcher almanac_fetcher2(profile(), nullptr);
  base::test::TestFuture<const std::vector<Result>&> waiter2;
  base::CallbackListSubscription subscription2 =
      almanac_fetcher2.RegisterForAppUpdates(waiter2.GetRepeatingCallback());
  EXPECT_EQ(waiter2.Take().size(), 2u);
  EXPECT_EQ(almanac_fetcher()->GetLastAppsUpdateTime(), before_download);
}

TEST_F(AlmanacFetcherTest, GetAppsUpdateOnSecondLogin) {
  // Check there are no apps before the update.
  almanac_fetcher()->GetApps(base::BindLambdaForTesting(
      [](const std::vector<Result>& results, DiscoveryError error) {
        EXPECT_EQ(error, DiscoveryError::kErrorRequestFailed);
        EXPECT_EQ(results.size(), 0u);
      }));

  base::Time before_download = almanac_fetcher()->GetLastAppsUpdateTime();
  base::test::TestFuture<const std::vector<Result>&> waiter;
  base::CallbackListSubscription subscription =
      almanac_fetcher()->RegisterForAppUpdates(waiter.GetRepeatingCallback());
  std::vector<Result> results = waiter.Take();
  EXPECT_EQ(results.size(), 2u);
  EXPECT_GT(almanac_fetcher()->GetLastAppsUpdateTime(), before_download);

  almanac_fetcher()->GetApps(base::BindLambdaForTesting(
      [](const std::vector<Result>& results, DiscoveryError error) {
        EXPECT_EQ(error, DiscoveryError::kSuccess);
        ASSERT_EQ(results.size(), 2u);
        EXPECT_EQ(results[0].GetAppSource(), AppSource::kGames);
        EXPECT_EQ(results[0].GetIconId(), "http://icon1/");
        EXPECT_EQ(results[0].GetAppTitle(), u"app_name1");
        ASSERT_TRUE(results[0].GetSourceExtras());
        auto* game_extras = results[0].GetSourceExtras()->AsGameExtras();
        ASSERT_TRUE(game_extras);
        EXPECT_EQ(game_extras->GetSource(), u"GeForce NOW");
        EXPECT_EQ(game_extras->GetDeeplinkUrl(),
                  GURL("https://game-deeplink.com/jrioj324j2095245234320o"));
        EXPECT_TRUE(game_extras->GetIsIconMaskingAllowed());

        EXPECT_EQ(results[1].GetAppSource(), AppSource::kGames);
        EXPECT_EQ(results[1].GetIconId(), "http://icon2/");
        EXPECT_EQ(results[1].GetAppTitle(), u"app_name2");
        EXPECT_TRUE(results[1].GetSourceExtras());
        game_extras = results[1].GetSourceExtras()->AsGameExtras();
        ASSERT_TRUE(game_extras);
        EXPECT_EQ(game_extras->GetSource(), u"Play");
        EXPECT_EQ(game_extras->GetDeeplinkUrl(),
                  GURL("https://game-deeplink.com/"
                       "reijarowaiore131983u12jkljs893"));
        EXPECT_FALSE(game_extras->GetIsIconMaskingAllowed());
      }));

  // Re-set to initiate a new login.
  almanac_fetcher()->SetLastAppsUpdateTime(before_download);
  SetServerResponse(url_loader_factory_, proto_loader(), kOneApp);
  AlmanacFetcher almanac_fetcher2(profile(), nullptr);
  base::test::TestFuture<const std::vector<Result>&> waiter2;
  base::CallbackListSubscription subscription2 =
      almanac_fetcher2.RegisterForAppUpdates(waiter2.GetRepeatingCallback());
  // Check the apps are overwritten on the second login.
  EXPECT_EQ(waiter2.Take().size(), 1u);
  EXPECT_GT(almanac_fetcher()->GetLastAppsUpdateTime(), before_download);
  almanac_fetcher2.GetApps(base::BindLambdaForTesting(
      [](const std::vector<Result>& results, DiscoveryError error) {
        EXPECT_EQ(error, DiscoveryError::kSuccess);
        ASSERT_EQ(results.size(), 1u);
        EXPECT_EQ(results[0].GetAppSource(), AppSource::kGames);
        EXPECT_EQ(results[0].GetIconId(), "http://icon/");
        EXPECT_EQ(results[0].GetAppTitle(), u"app_name");
        ASSERT_TRUE(results[0].GetSourceExtras());
        auto* game_extras = results[0].GetSourceExtras()->AsGameExtras();
        ASSERT_TRUE(game_extras);
        EXPECT_EQ(game_extras->GetSource(), u"GeForce NOW");
        EXPECT_EQ(game_extras->GetDeeplinkUrl(),
                  GURL("https://game-deeplink.com/cf2be56486f11ee"));
        EXPECT_TRUE(game_extras->GetIsIconMaskingAllowed());
      }));
}

TEST_F(AlmanacFetcherTest, GetIconSuccess) {
  base::test::TestFuture<const std::vector<Result>&> waiter;
  base::CallbackListSubscription subscription =
      almanac_fetcher()->RegisterForAppUpdates(waiter.GetRepeatingCallback());
  ASSERT_EQ(waiter.Take().size(), 2u);

  base::test::TestFuture<gfx::ImageSkia, apps::DiscoveryError> result;
  std::string icon_url = "https://icon";
  gfx::Image expected_image = GetTestImage(IDR_DEFAULT_FAVICON);
  EXPECT_CALL(*icon_cache_, GetIcon(GURL(icon_url), _))
      .WillOnce(
          Invoke([&expected_image](
                     const GURL&,
                     base::OnceCallback<void(const gfx::Image&)> callback) {
            std::move(callback).Run(expected_image);
          }));
  almanac_fetcher()->GetIcon(
      icon_url, /*size_hint_in_dip=*/32,
      result.GetCallback<const gfx::ImageSkia&, apps::DiscoveryError>());
  EXPECT_TRUE(
      gfx::test::AreImagesEqual(gfx::Image(result.Get<0>()), expected_image));
  EXPECT_EQ(result.Get<1>(), DiscoveryError::kSuccess);
}

TEST_F(AlmanacFetcherTest, GetIconError) {
  base::test::TestFuture<const std::vector<Result>&> waiter;
  base::CallbackListSubscription subscription =
      almanac_fetcher()->RegisterForAppUpdates(waiter.GetRepeatingCallback());
  ASSERT_EQ(waiter.Take().size(), 2u);

  base::test::TestFuture<gfx::ImageSkia, apps::DiscoveryError> result;
  std::string icon_url = "https://icon";
  EXPECT_CALL(*icon_cache_, GetIcon(GURL(icon_url), _))
      .WillOnce(
          Invoke([&](const GURL&,
                     base::OnceCallback<void(const gfx::Image&)> callback) {
            std::move(callback).Run(gfx::Image());
          }));
  almanac_fetcher()->GetIcon(
      icon_url, /*size_hint_in_dip=*/32,
      result.GetCallback<const gfx::ImageSkia&, apps::DiscoveryError>());
  EXPECT_TRUE(result.Get<0>().isNull());
  EXPECT_EQ(result.Get<1>(), DiscoveryError::kErrorRequestFailed);
}

TEST_F(AlmanacFetcherTest, GetIconNoAppsError) {
  base::test::TestFuture<gfx::ImageSkia, apps::DiscoveryError> result;
  std::string icon_url = "https://icon";
  EXPECT_CALL(*icon_cache_, GetIcon(GURL(icon_url), _)).Times(0);
  almanac_fetcher()->GetIcon(
      icon_url, /*size_hint_in_dip=*/32,
      result.GetCallback<const gfx::ImageSkia&, apps::DiscoveryError>());
  EXPECT_TRUE(result.Get<0>().isNull());
  EXPECT_EQ(result.Get<1>(), DiscoveryError::kErrorRequestFailed);
}
}  // namespace
}  // namespace apps