chromium/ash/webui/help_app_ui/search/search_handler_unittest.cc

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

#include "ash/webui/help_app_ui/search/search_handler.h"
#include <cstddef>

#include "ash/webui/help_app_ui/search/search_concept.h"
#include "ash/webui/help_app_ui/search/search_tag_registry.h"
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/strings/utf_string_conversions.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/task_environment.h"
#include "base/test/test_future.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "testing/gtest/include/gtest/gtest.h"

namespace ash::help_app {
namespace {

class FakeObserver : public mojom::SearchResultsObserver {
 public:
  FakeObserver() = default;
  ~FakeObserver() override = default;

  mojo::PendingRemote<mojom::SearchResultsObserver> GenerateRemote() {
    mojo::PendingRemote<mojom::SearchResultsObserver> remote;
    receiver_.Bind(remote.InitWithNewPipeAndPassReceiver());
    return remote;
  }

  size_t num_calls() const { return num_calls_; }

 private:
  // mojom::SearchResultsObserver:
  void OnSearchResultAvailabilityChanged() override { ++num_calls_; }

  size_t num_calls_ = 0;
  mojo::Receiver<mojom::SearchResultsObserver> receiver_{this};
};

}  // namespace

class HelpAppSearchHandlerTest : public testing::Test {
 protected:
  HelpAppSearchHandlerTest()
      : search_tag_registry_(local_search_service_proxy_.get()),
        handler_(&search_tag_registry_, local_search_service_proxy_.get()) {}
  ~HelpAppSearchHandlerTest() override = default;

  // testing::Test:
  void SetUp() override {
    handler_.BindInterface(handler_remote_.BindNewPipeAndPassReceiver());

    handler_remote_->Observe(search_results_observer_.GenerateRemote());
    handler_remote_.FlushForTesting();

    ASSERT_TRUE(temp_dir_.CreateUniqueTempDir());
  }

  void SetupInitialPersistenceSearchConcepts() {
    SearchConcept persistence(GetPersistencePath());
    std::vector<mojom::SearchConceptPtr> search_concepts;
    mojom::SearchConceptPtr new_concept_1 = mojom::SearchConcept::New(
        /*id=*/"test-id-1",
        /*title=*/u"Title 1",
        /*main_category=*/u"Help",
        /*tags=*/std::vector<std::u16string>{u"Test tag", u"Tag 2"},
        /*tag_locale=*/"en",
        /*url_path_with_parameters=*/"help",
        /*locale=*/"");
    mojom::SearchConceptPtr new_concept_2 = mojom::SearchConcept::New(
        /*id=*/"test-id-2",
        /*title=*/u"Title 2",
        /*main_category=*/u"Help",
        /*tags=*/std::vector<std::u16string>{u"Another test tag"},
        /*tag_locale=*/"en",
        /*url_path_with_parameters=*/"help",
        /*locale=*/"");
    search_concepts.push_back(std::move(new_concept_1));
    search_concepts.push_back(std::move(new_concept_2));
    persistence.UpdateSearchConcepts(search_concepts);
    task_environment_.RunUntilIdle();
    EXPECT_TRUE(base::PathExists(GetPersistencePath()));
  }

  void SimulateWebDataUpdate() {
    std::vector<mojom::SearchConceptPtr> new_search_concepts;
    mojom::SearchConceptPtr new_concept_1 = mojom::SearchConcept::New(
        /*id=*/"test-id-1",
        /*title=*/u"Title 1",
        /*main_category=*/u"Help",
        /*tags=*/std::vector<std::u16string>{u"Printing"},
        /*tag_locale=*/"en",
        /*url_path_with_parameters=*/"help",
        /*locale=*/"");
    new_search_concepts.push_back(std::move(new_concept_1));
    Update(std::move(new_search_concepts));
    handler_remote_.FlushForTesting();
    task_environment_.RunUntilIdle();
  }

  base::FilePath GetTempPath() { return temp_dir_.GetPath(); }

  base::FilePath GetPersistencePath() {
    return temp_dir_.GetPath()
        .AppendASCII("help_app/")
        .AppendASCII("persistence.pb");
  }

  void OnRead(size_t expected_size,
              std::vector<mojom::SearchConceptPtr> search_concepts) {
    EXPECT_EQ(search_concepts.size(), expected_size);
  }

  base::OnceCallback<void(std::vector<mojom::SearchConceptPtr>)> ReadCallback(
      size_t expected_size) {
    return base::BindOnce(&HelpAppSearchHandlerTest::OnRead,
                          base::Unretained(this), expected_size);
  }

  std::vector<mojom::SearchResultPtr> Search(const std::u16string& query,
                                             int32_t max_num_results) {
    base::test::TestFuture<std::vector<mojom::SearchResultPtr>> future;
    handler_remote_->Search(query, max_num_results, future.GetCallback());
    return future.Take();
  }

  void Update(std::vector<mojom::SearchConceptPtr> search_concepts) {
    base::test::TestFuture<void> future;
    handler_remote_->Update(std::move(search_concepts), future.GetCallback());
    EXPECT_TRUE(future.Wait());
  }

  base::test::TaskEnvironment task_environment_;

  std::unique_ptr<local_search_service::LocalSearchServiceProxy>
      local_search_service_proxy_ =
          std::make_unique<local_search_service::LocalSearchServiceProxy>(
              /*for_testing=*/true);
  SearchTagRegistry search_tag_registry_;
  SearchHandler handler_;
  mojo::Remote<mojom::SearchHandler> handler_remote_;
  FakeObserver search_results_observer_;
  base::ScopedTempDir temp_dir_;
};

TEST_F(HelpAppSearchHandlerTest, UpdateAndSearch) {
  // Add some search tags.
  std::vector<mojom::SearchConceptPtr> search_concepts;
  mojom::SearchConceptPtr new_concept_1 = mojom::SearchConcept::New(
      /*id=*/"test-id-1",
      /*title=*/u"Title 1",
      /*main_category=*/u"Help",
      /*tags=*/std::vector<std::u16string>{u"Test tag", u"Tag 2"},
      /*tag_locale=*/"en",
      /*url_path_with_parameters=*/"help",
      /*locale=*/"");
  mojom::SearchConceptPtr new_concept_2 = mojom::SearchConcept::New(
      /*id=*/"test-id-2",
      /*title=*/u"Title 2",
      /*main_category=*/u"Help",
      /*tags=*/std::vector<std::u16string>{u"Another test tag"},
      /*tag_locale=*/"en",
      /*url_path_with_parameters=*/"help",
      /*locale=*/"");
  search_concepts.push_back(std::move(new_concept_1));
  search_concepts.push_back(std::move(new_concept_2));

  Update(std::move(search_concepts));
  handler_remote_.FlushForTesting();
  task_environment_.RunUntilIdle();

  EXPECT_EQ(1u, search_results_observer_.num_calls());

  std::vector<mojom::SearchResultPtr> search_results;

  // 2 results should be available for a "test tag" query.
  search_results = Search(u"test tag",
                          /*max_num_results=*/3u);
  EXPECT_EQ(search_results.size(), 2u);

  // Limit results to 1 max and ensure that only 1 result is returned.
  search_results = Search(u"test tag",
                          /*max_num_results=*/1u);
  EXPECT_EQ(search_results.size(), 1u);

  // Search for a query which should return no results.
  search_results = Search(u"QueryWithNoResults",
                          /*max_num_results=*/3u);
  EXPECT_TRUE(search_results.empty());
}

TEST_F(HelpAppSearchHandlerTest, SearchResultMetadata) {
  // Add some search tags.
  std::vector<mojom::SearchConceptPtr> search_concepts;
  mojom::SearchConceptPtr new_concept_1 = mojom::SearchConcept::New(
      /*id=*/"test-id-1",
      /*title=*/u"Title 1",
      /*main_category=*/u"Help",
      /*tags=*/std::vector<std::u16string>{u"Test tag", u"Printing"},
      /*tag_locale=*/"en",
      /*url_path_with_parameters=*/"help",
      /*locale=*/"");
  search_concepts.push_back(std::move(new_concept_1));

  Update(std::move(search_concepts));
  handler_remote_.FlushForTesting();
  task_environment_.RunUntilIdle();

  std::vector<mojom::SearchResultPtr> search_results;
  search_results = Search(u"Printing",
                          /*max_num_results=*/3u);

  EXPECT_EQ(search_results.size(), 1u);
  EXPECT_EQ(search_results[0]->id, "test-id-1");
  EXPECT_EQ(search_results[0]->title, u"Title 1");
  EXPECT_EQ(search_results[0]->main_category, u"Help");
  EXPECT_EQ(search_results[0]->locale, "");
  EXPECT_GT(search_results[0]->relevance_score, 0.01);
}

TEST_F(HelpAppSearchHandlerTest, SearchResultOrdering) {
  // Add some search tags.
  std::vector<mojom::SearchConceptPtr> search_concepts;
  mojom::SearchConceptPtr new_concept_1 = mojom::SearchConcept::New(
      /*id=*/"test-id-less",
      /*title=*/u"Title 1",
      /*main_category=*/u"Help",
      /*tags=*/std::vector<std::u16string>{u"less relevant concept"},
      /*tag_locale=*/"en",
      /*url_path_with_parameters=*/"help",
      /*locale=*/"");
  mojom::SearchConceptPtr new_concept_2 = mojom::SearchConcept::New(
      /*id=*/"test-id-more",
      /*title=*/u"Title 2",
      /*main_category=*/u"Help",
      /*tags=*/std::vector<std::u16string>{u"more relevant tag", u"Tag"},
      /*tag_locale=*/"en",
      /*url_path_with_parameters=*/"help",
      /*locale=*/"");
  search_concepts.push_back(std::move(new_concept_1));
  search_concepts.push_back(std::move(new_concept_2));

  Update(std::move(search_concepts));
  handler_remote_.FlushForTesting();
  task_environment_.RunUntilIdle();

  std::vector<mojom::SearchResultPtr> search_results;
  search_results = Search(u"relevant tag",
                          /*max_num_results=*/3u);

  // The more relevant concept should be first, but the other concept still has
  // some relevance.
  ASSERT_EQ(search_results.size(), 2u);
  EXPECT_EQ(search_results[0]->id, "test-id-more");
  EXPECT_EQ(search_results[1]->id, "test-id-less");
  EXPECT_GT(search_results[0]->relevance_score,
            search_results[1]->relevance_score);
  EXPECT_GT(search_results[1]->relevance_score, 0.01);
}

TEST_F(HelpAppSearchHandlerTest, SearchStatusNotReadyAndEmptyIndex) {
  base::HistogramTester histogram_tester;
  std::vector<mojom::SearchResultPtr> search_results;

  // Search without updating the index.
  search_results = Search(u"test query", /*max_num_results=*/3u);

  EXPECT_TRUE(search_results.empty());
  // 0 is kNotReadyAndEmptyIndex.
  histogram_tester.ExpectUniqueSample(
      "Discover.SearchHandler.SearchResultStatus", 0, 1);
}

TEST_F(HelpAppSearchHandlerTest, SearchStatusReadyAndSuccess) {
  // Add one item to the search index.
  std::vector<mojom::SearchConceptPtr> search_concepts;
  mojom::SearchConceptPtr new_concept_1 = mojom::SearchConcept::New(
      /*id=*/"test-id-1",
      /*title=*/u"Title 1",
      /*main_category=*/u"Help",
      /*tags=*/std::vector<std::u16string>{u"Test tag", u"Printing"},
      /*tag_locale=*/"en",
      /*url_path_with_parameters=*/"help",
      /*locale=*/"");
  search_concepts.push_back(std::move(new_concept_1));
  Update(std::move(search_concepts));
  handler_remote_.FlushForTesting();
  task_environment_.RunUntilIdle();

  base::HistogramTester histogram_tester;
  std::vector<mojom::SearchResultPtr> search_results;

  search_results = Search(u"Printing", /*max_num_results=*/3u);

  EXPECT_EQ(search_results.size(), 1u);
  // 2 is kReadyAndSuccess.
  histogram_tester.ExpectUniqueSample(
      "Discover.SearchHandler.SearchResultStatus", 2, 1);
}

TEST_F(HelpAppSearchHandlerTest, SearchStatusReadyAndEmptyIndex) {
  // Update using an empty list. This can happen if there is no localized
  // content for the current locale.
  std::vector<mojom::SearchConceptPtr> search_concepts;
  Update(std::move(search_concepts));
  handler_remote_.FlushForTesting();
  task_environment_.RunUntilIdle();

  base::HistogramTester histogram_tester;
  std::vector<mojom::SearchResultPtr> search_results;

  search_results = Search(u"Printing", /*max_num_results=*/3u);

  EXPECT_TRUE(search_results.empty());
  // 3 is kReadyAndEmptyIndex.
  histogram_tester.ExpectUniqueSample(
      "Discover.SearchHandler.SearchResultStatus", 3, 1);
}

TEST_F(HelpAppSearchHandlerTest, SearchStatusReadyAndOtherStatus) {
  // Add one item to the search index.
  std::vector<mojom::SearchConceptPtr> search_concepts;
  mojom::SearchConceptPtr new_concept_1 = mojom::SearchConcept::New(
      /*id=*/"test-id-1",
      /*title=*/u"Title 1",
      /*main_category=*/u"Help",
      /*tags=*/std::vector<std::u16string>{u"Test tag", u"Printing"},
      /*tag_locale=*/"en",
      /*url_path_with_parameters=*/"help",
      /*locale=*/"");
  search_concepts.push_back(std::move(new_concept_1));
  Update(std::move(search_concepts));
  handler_remote_.FlushForTesting();
  task_environment_.RunUntilIdle();

  base::HistogramTester histogram_tester;
  std::vector<mojom::SearchResultPtr> search_results;

  // Searching with an empty query results in a different status: kEmptyQuery.
  search_results = Search(u"", /*max_num_results=*/3u);

  EXPECT_TRUE(search_results.empty());
  // 4 is kReadyAndOtherStatus.
  histogram_tester.ExpectUniqueSample(
      "Discover.SearchHandler.SearchResultStatus", 4, 1);
}

TEST_F(HelpAppSearchHandlerTest, InitializeWithoutPersistence) {
  // Load when persistence not exist.
  EXPECT_FALSE(base::PathExists(GetPersistencePath()));
  handler_.OnProfileDirAvailable(GetTempPath());
  handler_remote_.FlushForTesting();
  task_environment_.RunUntilIdle();

  // Nothing is updated.
  EXPECT_EQ(0u, search_results_observer_.num_calls());

  // Web data comes.
  SimulateWebDataUpdate();

  // Updated from web data.
  EXPECT_EQ(1u, search_results_observer_.num_calls());

  // Check the persistence is generated.
  EXPECT_TRUE(base::PathExists(GetPersistencePath()));
  SearchConcept persistence(GetPersistencePath());
  persistence.GetSearchConcepts(ReadCallback(1u));
}

TEST_F(HelpAppSearchHandlerTest, InitializeWithPersistence) {
  // Add persistence to disk.
  SetupInitialPersistenceSearchConcepts();

  // Load from persistence.
  handler_.OnProfileDirAvailable(GetTempPath());
  handler_remote_.FlushForTesting();
  task_environment_.RunUntilIdle();

  // Updated from persistence.
  EXPECT_EQ(1u, search_results_observer_.num_calls());

  std::vector<mojom::SearchResultPtr> search_results;

  // There should be results.
  search_results = Search(u"test tag",
                          /*max_num_results=*/3u);
  EXPECT_EQ(search_results.size(), 2u);
}

TEST_F(HelpAppSearchHandlerTest, PersistenceUpdateWithNewData) {
  // Add persistence to disk.
  SetupInitialPersistenceSearchConcepts();

  // Load from persistence.
  handler_.OnProfileDirAvailable(GetTempPath());
  handler_remote_.FlushForTesting();
  task_environment_.RunUntilIdle();

  // Updated from persistence.
  EXPECT_EQ(1u, search_results_observer_.num_calls());

  // Web data comes.
  SimulateWebDataUpdate();

  // Updated from new data.
  EXPECT_EQ(2u, search_results_observer_.num_calls());

  std::vector<mojom::SearchResultPtr> search_results;

  // There should be new results.
  search_results = Search(u"Printing", /*max_num_results=*/3u);
  EXPECT_EQ(search_results.size(), 1u);

  // There should be no old results.
  search_results = Search(u"test tag",
                          /*max_num_results=*/3u);
  EXPECT_EQ(search_results.size(), 0u);

  // Check the persistence is also updated.
  SearchConcept persistence(GetPersistencePath());
  persistence.GetSearchConcepts(ReadCallback(1u));
}

TEST_F(HelpAppSearchHandlerTest, NewDataComesBeforePersistenceLoad) {
  // Add persistence to disk.
  SetupInitialPersistenceSearchConcepts();

  // Web data comes.
  SimulateWebDataUpdate();

  // Updated from web data.
  EXPECT_EQ(1u, search_results_observer_.num_calls());

  // Load from persistence after the new data comes.
  handler_.OnProfileDirAvailable(GetTempPath());
  handler_remote_.FlushForTesting();
  task_environment_.RunUntilIdle();

  // No update from persistence.
  EXPECT_EQ(1u, search_results_observer_.num_calls());

  std::vector<mojom::SearchResultPtr> search_results;

  // There should be new results.
  search_results = Search(u"Printing", /*max_num_results=*/3u);
  EXPECT_EQ(search_results.size(), 1u);

  // There should be no persistence results.
  search_results = Search(u"test tag",
                          /*max_num_results=*/3u);
  EXPECT_EQ(search_results.size(), 0u);

  // Check the persistence is also updated.
  SearchConcept persistence(GetPersistencePath());
  persistence.GetSearchConcepts(ReadCallback(1u));
}

}  // namespace ash::help_app