chromium/chromeos/ash/components/file_manager/indexing/file_index_service_unittest.cc

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

#include "chromeos/ash/components/file_manager/indexing/file_index_service.h"

#include <string>
#include <string_view>
#include <vector>

#include "base/files/scoped_temp_dir.h"
#include "base/strings/strcat.h"
#include "base/test/bind.h"
#include "base/test/task_environment.h"
#include "base/threading/platform_thread.h"
#include "base/time/time.h"
#include "chromeos/ash/components/file_manager/indexing/file_info.h"
#include "chromeos/ash/components/file_manager/indexing/query.h"
#include "chromeos/ash/components/file_manager/indexing/term.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"
#include "url/url_canon.h"
#include "url/url_util.h"

namespace ash::file_manager {
namespace {

GURL MakeLocalURL(const std::string& file_name) {
  return GURL(base::StrCat(
      {"filesystem:chrome://file-manager/external/Downloads-user123/",
       file_name}));
}

GURL MakeDriveURL(const std::string& file_name) {
  return GURL(base::StrCat(
      {"filesystem:chrome://file-manager/external/drivefs-987654321/",
       file_name}));
}

MATCHER_P(ContainsFiles, expected_files, "") {
  std::set<FileInfo> result_set;
  for (const Match& match : arg.matches) {
    result_set.emplace(match.file_info);
  }
  std::set<FileInfo> expected_set;
  for (const FileInfo& info : expected_files) {
    expected_set.emplace(info);
  }
  return expected_set == result_set;
}

class FileIndexServiceTest : public testing::Test {
 public:
  FileIndexServiceTest()
      : pinned_("label", u"pinned"),
        downloaded_("label", u"downloaded"),
        starred_("label", u"starred") {}

  void SetUp() override {
    ASSERT_TRUE(temp_dir_.CreateUniqueTempDir());
    scheme_registry_ = std::make_unique<url::ScopedSchemeRegistryForTests>();
    url::AddStandardScheme("chrome", url::SCHEME_WITH_HOST);
    // These URLs must be created after scheme_registry_ has 'chrome' scheme
    // added to it. Otherwise, they are deemed invalid.
    foo_url_ = MakeLocalURL("foo.txt");
    bar_url_ = MakeLocalURL("bar.txt");
    CreateIndex();
  }

  void TearDown() override {
    DestroyIndex();
    ASSERT_TRUE(temp_dir_.Delete());
  }

  // Convenience methods that convert asynchronous results to synchronous.
  void CreateIndex() {
    base::FilePath db_path =
        temp_dir_.GetPath().Append("FileIndexServiceTest.db");
    index_service_ = std::make_unique<FileIndexService>(db_path);
    ASSERT_EQ(Init(), OpResults::kSuccess);
  }

  void DestroyIndex() { index_service_.reset(); }

  OpResults Init() {
    base::RunLoop run_loop;
    OpResults outcome;
    index_service_->Init(base::BindLambdaForTesting([&](OpResults results) {
      outcome = results;
      run_loop.Quit();
    }));
    run_loop.Run();
    return outcome;
  }

  SearchResults Search(const Query& query) {
    base::RunLoop run_loop;
    SearchResults outcome;
    index_service_->Search(
        query, base::BindLambdaForTesting([&](SearchResults results) {
          outcome.total_matches = results.total_matches;
          outcome.matches = results.matches;
          run_loop.Quit();
        }));
    run_loop.Run();
    return outcome;
  }

  OpResults PutFileInfo(const FileInfo& file_info) {
    base::RunLoop run_loop;
    OpResults outcome;
    index_service_->PutFileInfo(
        file_info, base::BindLambdaForTesting([&](OpResults results) {
          outcome = results;
          run_loop.Quit();
        }));
    run_loop.Run();
    return outcome;
  }

  OpResults SetTerms(const std::vector<Term> terms, const GURL& url) {
    base::RunLoop run_loop;
    OpResults outcome;
    index_service_->SetTerms(terms, url,
                             base::BindLambdaForTesting([&](OpResults results) {
                               outcome = results;
                               run_loop.Quit();
                             }));
    run_loop.Run();
    return outcome;
  }

  OpResults AddTerms(const std::vector<Term> terms, const GURL& url) {
    base::RunLoop run_loop;
    OpResults outcome;
    index_service_->AddTerms(terms, url,
                             base::BindLambdaForTesting([&](OpResults results) {
                               outcome = results;
                               run_loop.Quit();
                             }));
    run_loop.Run();
    return outcome;
  }

  OpResults RemoveTerms(const std::vector<Term> terms, const GURL& url) {
    base::RunLoop run_loop;
    OpResults outcome;
    index_service_->RemoveTerms(
        terms, url, base::BindLambdaForTesting([&](OpResults results) {
          outcome = results;
          run_loop.Quit();
        }));
    run_loop.Run();
    return outcome;
  }

  OpResults RemoveFile(const GURL& url) {
    base::RunLoop run_loop;
    OpResults outcome;
    index_service_->RemoveFile(
        url, base::BindLambdaForTesting([&](OpResults results) {
          outcome = results;
          run_loop.Quit();
        }));
    run_loop.Run();
    return outcome;
  }

  OpResults MoveFile(const GURL& old_url, const GURL& new_url) {
    base::RunLoop run_loop;
    OpResults outcome;
    index_service_->MoveFile(old_url, new_url,
                             base::BindLambdaForTesting([&](OpResults results) {
                               outcome = results;
                               run_loop.Quit();
                             }));
    run_loop.Run();
    return outcome;
  }

  Term pinned_;
  Term downloaded_;
  Term starred_;
  GURL foo_url_;
  GURL bar_url_;
  std::unique_ptr<FileIndexService> index_service_;
  base::ScopedTempDir temp_dir_;
  // Allows registering the "chrome://" scheme, without depending on //content.
  std::unique_ptr<url::ScopedSchemeRegistryForTests> scheme_registry_;
  base::test::TaskEnvironment task_environment_;
};

typedef std::vector<FileInfo> FileInfoList;

TEST_F(FileIndexServiceTest, InitializeTwice) {
  ASSERT_EQ(Init(), OpResults::kSuccess);
}

TEST_F(FileIndexServiceTest, CreateDestroyCreate) {
  // Index is already created by SetUp(). Thus just destroy it and create.
  // it again.
  DestroyIndex();
  // TODO(b:327534824): Remove the sleep statement.
  base::PlatformThread::Sleep(base::Milliseconds(250));
  CreateIndex();
}

TEST_F(FileIndexServiceTest, EmptySearch) {
  // Empty query on an empty index.
  EXPECT_THAT(Search(Query({})), ContainsFiles(FileInfoList{}));

  FileInfo file_info(foo_url_, 1024, base::Time());
  EXPECT_EQ(PutFileInfo(file_info), OpResults::kSuccess);
  EXPECT_EQ(SetTerms({pinned_}, file_info.file_url), OpResults::kSuccess);

  // Empty query on an non-empty index.
  EXPECT_THAT(Search(Query({})), ContainsFiles(FileInfoList{}));
}

TEST_F(FileIndexServiceTest, SimpleMatch) {
  FileInfo file_info(foo_url_, 1024, base::Time());

  EXPECT_EQ(PutFileInfo(file_info), OpResults::kSuccess);
  EXPECT_EQ(SetTerms({pinned_}, file_info.file_url), OpResults::kSuccess);
  EXPECT_THAT(Search(Query({pinned_})), ContainsFiles(FileInfoList{file_info}));
}

TEST_F(FileIndexServiceTest, MultiTermMatch) {
  FileInfo file_info(foo_url_, 1024, base::Time());

  // Label file_info as pinned and starred.
  EXPECT_EQ(PutFileInfo(file_info), OpResults::kSuccess);
  EXPECT_EQ(SetTerms({pinned_, starred_}, file_info.file_url),
            OpResults::kSuccess);

  EXPECT_THAT(Search(Query({pinned_})), ContainsFiles(FileInfoList{file_info}));

  EXPECT_THAT(Search(Query({starred_})),
              ContainsFiles(FileInfoList{file_info}));

  EXPECT_THAT(Search(Query({pinned_, starred_})),
              ContainsFiles(FileInfoList{file_info}));
}

TEST_F(FileIndexServiceTest, AddTerms) {
  FileInfo file_info(foo_url_, 1024, base::Time());

  EXPECT_EQ(PutFileInfo(file_info), OpResults::kSuccess);
  // Label file_info as pinned and starred.
  EXPECT_EQ(SetTerms({downloaded_}, file_info.file_url), OpResults::kSuccess);

  // Can find by downloaded.
  EXPECT_THAT(Search(Query({downloaded_})),
              ContainsFiles(FileInfoList{file_info}));
  // Cannot find by starred.
  EXPECT_THAT(Search(Query({starred_})), ContainsFiles(FileInfoList{}));

  EXPECT_EQ(AddTerms({starred_}, foo_url_), OpResults::kSuccess);
  // Can find by downloaded.
  EXPECT_THAT(Search(Query({downloaded_})),
              ContainsFiles(FileInfoList{file_info}));
  // And by starred.
  EXPECT_THAT(Search(Query({starred_})),
              ContainsFiles(FileInfoList{file_info}));
  // And by starred and downloaded.
  EXPECT_THAT(Search(Query({starred_, downloaded_})),
              ContainsFiles(FileInfoList{file_info}));
}

TEST_F(FileIndexServiceTest, ReplaceTerms) {
  FileInfo file_info(foo_url_, 1024, base::Time());

  EXPECT_EQ(PutFileInfo(file_info), OpResults::kSuccess);
  // Start with the single label: downloaded.
  EXPECT_EQ(SetTerms({downloaded_}, file_info.file_url), OpResults::kSuccess);
  EXPECT_THAT(Search(Query({downloaded_})),
              ContainsFiles(FileInfoList{file_info}));
  EXPECT_THAT(Search(Query({starred_})), ContainsFiles(FileInfoList{}));

  // Just adding more labels: both downloaded and starred.
  EXPECT_EQ(SetTerms({downloaded_, starred_}, file_info.file_url),
            OpResults::kSuccess);
  EXPECT_THAT(Search(Query({downloaded_})),
              ContainsFiles(FileInfoList{file_info}));
  EXPECT_THAT(Search(Query({starred_})),
              ContainsFiles(FileInfoList{file_info}));

  // Remove the original "downloaded" label.
  EXPECT_EQ(SetTerms({starred_}, file_info.file_url), OpResults::kSuccess);
  EXPECT_THAT(Search(Query({downloaded_})), ContainsFiles(FileInfoList{}));
  EXPECT_THAT(Search(Query({starred_})),
              ContainsFiles(FileInfoList{file_info}));

  // Remove the "starred" label and add back "downloaded".
  EXPECT_EQ(SetTerms({downloaded_}, file_info.file_url), OpResults::kSuccess);
  EXPECT_THAT(Search(Query({downloaded_})),
              ContainsFiles(FileInfoList{file_info}));
  EXPECT_THAT(Search(Query({starred_})), ContainsFiles(FileInfoList({})));
}

TEST_F(FileIndexServiceTest, SearchMultipleFiles) {
  FileInfo foo_file_info(foo_url_, 1024, base::Time());

  EXPECT_EQ(PutFileInfo(foo_file_info), OpResults::kSuccess);
  EXPECT_EQ(SetTerms({downloaded_}, foo_file_info.file_url),
            OpResults::kSuccess);

  GURL bar_drive_url = MakeDriveURL("bar.txt");
  FileInfo bar_file_info(bar_drive_url, 1024, base::Time());
  EXPECT_EQ(PutFileInfo(bar_file_info), OpResults::kSuccess);
  EXPECT_EQ(SetTerms({downloaded_}, bar_file_info.file_url),
            OpResults::kSuccess);

  EXPECT_THAT(Search(Query({downloaded_})),
              ContainsFiles(FileInfoList{foo_file_info, bar_file_info}));
}

TEST_F(FileIndexServiceTest, SearchByNonexistingTerms) {
  FileInfo file_info(foo_url_, 1024, base::Time());
  EXPECT_EQ(PutFileInfo(file_info), OpResults::kSuccess);

  EXPECT_EQ(SetTerms({pinned_}, file_info.file_url), OpResults::kSuccess);

  EXPECT_THAT(Search(Query({downloaded_})), ContainsFiles(FileInfoList{}));
}

TEST_F(FileIndexServiceTest, EmptySetTermsIsInvalid) {
  FileInfo file_info(foo_url_, 1024, base::Time());
  EXPECT_EQ(PutFileInfo(file_info), OpResults::kSuccess);

  // Insert into the index with pinned label.
  EXPECT_EQ(SetTerms({pinned_}, file_info.file_url), OpResults::kSuccess);
  // Verify that passing empty terms is disallowed.
  EXPECT_EQ(SetTerms({}, file_info.file_url), OpResults::kArgumentError);

  EXPECT_THAT(Search(Query({pinned_})), ContainsFiles(FileInfoList{file_info}));
}

TEST_F(FileIndexServiceTest, FieldSeparator) {
  Term colon_in_field("foo:", u"one");
  FileInfo foo_info(foo_url_, 1024, base::Time());
  EXPECT_EQ(PutFileInfo(foo_info), OpResults::kSuccess);

  EXPECT_EQ(SetTerms({colon_in_field}, foo_info.file_url), OpResults::kSuccess);

  Term colon_in_text("foo", u":one");
  FileInfo bar_info(bar_url_, 1024, base::Time());
  EXPECT_EQ(PutFileInfo(bar_info), OpResults::kSuccess);
  EXPECT_EQ(SetTerms({colon_in_text}, bar_info.file_url), OpResults::kSuccess);

  EXPECT_THAT(Search(Query({colon_in_field})),
              ContainsFiles(FileInfoList{foo_info}));
  EXPECT_THAT(Search(Query({colon_in_text})),
              ContainsFiles(FileInfoList{bar_info}));
}

TEST_F(FileIndexServiceTest, GlobalSearch) {
  // Setup: two files, one marked with the label:starred, the other with
  // content:starred. This simulates the case where identical tokens, "starred"
  // came from two different sources (labeling, and file content).
  const std::u16string text = u"starred";
  Term label_term("label", text);
  Term content_term("content", text);
  FileInfo labeled_info(foo_url_, 1024, base::Time());
  FileInfo content_info(bar_url_, 1024, base::Time());

  EXPECT_EQ(PutFileInfo(labeled_info), OpResults::kSuccess);
  EXPECT_EQ(PutFileInfo(content_info), OpResults::kSuccess);

  EXPECT_EQ(SetTerms({label_term}, labeled_info.file_url), OpResults::kSuccess);
  EXPECT_EQ(SetTerms({content_term}, content_info.file_url),
            OpResults::kSuccess);

  // Searching with empty field name means global space search.
  EXPECT_THAT(Search(Query({Term("", text)})),
              ContainsFiles(FileInfoList{labeled_info, content_info}));
  // Searching with field name, gives us unique results.
  EXPECT_THAT(Search(Query({label_term})),
              ContainsFiles(FileInfoList{labeled_info}));
  EXPECT_THAT(Search(Query({content_term})),
              ContainsFiles(FileInfoList{content_info}));
}

TEST_F(FileIndexServiceTest, MixedSearch) {
  // Setup: two files, both starred, one labeled "tax", one containing the word
  // "tax" in its content.
  const std::u16string tax_text = u"tax";
  Term tax_content_term("content", tax_text);
  Term tax_label_term("label", tax_text);
  FileInfo tax_label_info(foo_url_, 1024, base::Time());
  FileInfo tax_content_info(bar_url_, 1024, base::Time());

  EXPECT_EQ(PutFileInfo(tax_label_info), OpResults::kSuccess);
  EXPECT_EQ(PutFileInfo(tax_content_info), OpResults::kSuccess);

  EXPECT_EQ(SetTerms({starred_, tax_content_term}, tax_content_info.file_url),
            OpResults::kSuccess);
  EXPECT_EQ(SetTerms({starred_, tax_label_term}, tax_label_info.file_url),
            OpResults::kSuccess);

  // Searching with "starred tax" should return both files.
  EXPECT_THAT(Search(Query({Term("", tax_text), Term("", u"starred")})),
              ContainsFiles(FileInfoList{tax_content_info, tax_label_info}));
  // Searching with with "label:starred content:tax" gives us just the file that
  // has "tax" in content.
  EXPECT_THAT(Search(Query({starred_, tax_content_term})),
              ContainsFiles(FileInfoList{tax_content_info}));
  // Searching with with "label:starred label:tax" gives us just the file that
  // has "tax" as a label.
  EXPECT_THAT(Search(Query({starred_, tax_label_term})),
              ContainsFiles(FileInfoList{tax_label_info}));
}

TEST_F(FileIndexServiceTest, MoveFile) {
  // Test 1: Move non-existing file.
  EXPECT_EQ(MoveFile(foo_url_, bar_url_), OpResults::kFileMissing);

  // Test 2: Move file to itself.
  FileInfo foo_info(foo_url_, 1024, base::Time());
  EXPECT_EQ(PutFileInfo(foo_info), OpResults::kSuccess);
  EXPECT_EQ(MoveFile(foo_info.file_url, foo_info.file_url),
            OpResults::kSuccess);

  // Test 3: Move file onto existing file.
  FileInfo bar_info(bar_url_, 1024, base::Time());
  EXPECT_EQ(PutFileInfo(bar_info), OpResults::kSuccess);
  EXPECT_EQ(MoveFile(foo_info.file_url, bar_info.file_url),
            OpResults::kFileExists);

  // Test 4: Actually move the file to the new URL.
  // First setup terms and make sure we can find the file by those terms.
  EXPECT_EQ(SetTerms({starred_, downloaded_}, foo_info.file_url),
            OpResults::kSuccess);
  EXPECT_THAT(Search(Query({starred_})), ContainsFiles(FileInfoList{foo_info}));
  EXPECT_THAT(Search(Query({downloaded_})),
              ContainsFiles(FileInfoList{foo_info}));

  // Now actually move the file and verify that we cannot match the old foo_info
  // but can match foo_info_new.
  GURL new_foo_url = MakeLocalURL("foo2.txt");
  FileInfo new_foo_info(new_foo_url, foo_info.size, foo_info.last_modified);
  EXPECT_EQ(MoveFile(foo_info.file_url, new_foo_url), OpResults::kSuccess);
  EXPECT_THAT(Search(Query({starred_})),
              ContainsFiles(FileInfoList{new_foo_info}));
  EXPECT_THAT(Search(Query({downloaded_})),
              ContainsFiles(FileInfoList{new_foo_info}));
  // We cannot directly check if a given URL exists in the system, but we
  // can try to update terms using old URL and expect a kFileMissing error.
  EXPECT_EQ(SetTerms({starred_}, foo_info.file_url), OpResults::kFileMissing);
}

TEST_F(FileIndexServiceTest, RemoveFile) {
  // Empty remove.
  FileInfo foo_info(foo_url_, 1024, base::Time());
  EXPECT_EQ(RemoveFile(foo_info.file_url), OpResults::kSuccess);
  // Add foo_info to the index.
  EXPECT_EQ(PutFileInfo(foo_info), OpResults::kSuccess);
  EXPECT_EQ(SetTerms({starred_}, foo_info.file_url), OpResults::kSuccess);
  EXPECT_THAT(Search(Query({starred_})), ContainsFiles(FileInfoList{foo_info}));
  EXPECT_EQ(RemoveFile(foo_info.file_url), OpResults::kSuccess);
  EXPECT_THAT(Search(Query({starred_})), ContainsFiles(FileInfoList{}));
}

TEST_F(FileIndexServiceTest, RemoveTerms) {
  FileInfo foo_info(foo_url_, 1024, base::Time());

  EXPECT_EQ(RemoveTerms({}, foo_url_), OpResults::kSuccess);

  // Add terms for foo_info.
  EXPECT_EQ(PutFileInfo(foo_info), OpResults::kSuccess);
  EXPECT_EQ(SetTerms({starred_, downloaded_}, foo_info.file_url),
            OpResults::kSuccess);
  EXPECT_THAT(Search(Query({starred_})), ContainsFiles(FileInfoList{foo_info}));
  EXPECT_THAT(Search(Query({downloaded_})),
              ContainsFiles(FileInfoList{foo_info}));

  EXPECT_EQ(RemoveTerms({starred_}, foo_info.file_url), OpResults::kSuccess);

  EXPECT_TRUE(Search(Query({starred_})).matches.empty());
  EXPECT_THAT(Search(Query({downloaded_})),
              ContainsFiles(FileInfoList{foo_info}));

  // Remove more terms, including one that is no longer there.
  EXPECT_EQ(RemoveTerms({starred_, downloaded_}, foo_info.file_url),
            OpResults::kSuccess);

  EXPECT_TRUE(Search(Query({starred_})).matches.empty());
  EXPECT_TRUE(Search(Query({downloaded_})).matches.empty());
}

TEST_F(FileIndexServiceTest, AddOrSetBeforePut) {
  EXPECT_EQ(SetTerms({starred_}, foo_url_), OpResults::kFileMissing);
  EXPECT_EQ(AddTerms({starred_}, foo_url_), OpResults::kFileMissing);
}

}  // namespace
}  // namespace ash::file_manager