910e62b5创建于 1月15日历史提交
// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "components/browsing_topics/browsing_topics_state.h"

#include <algorithm>

#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/functional/callback_helpers.h"
#include "base/json/json_file_value_serializer.h"
#include "base/json/values_util.h"
#include "base/strings/strcat.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_util.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/task_environment.h"
#include "components/browsing_topics/util.h"
#include "services/network/public/cpp/features.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/features.h"

namespace browsing_topics {

namespace {

constexpr base::Time kTime1 =
    base::Time::FromDeltaSinceWindowsEpoch(base::Days(1));
constexpr base::Time kTime2 =
    base::Time::FromDeltaSinceWindowsEpoch(base::Days(2));
constexpr base::Time kTime3 =
    base::Time::FromDeltaSinceWindowsEpoch(base::Days(3));
constexpr base::Time kTime4 =
    base::Time::FromDeltaSinceWindowsEpoch(base::Days(4));
constexpr base::Time kTime5 =
    base::Time::FromDeltaSinceWindowsEpoch(base::Days(5));

constexpr browsing_topics::HmacKey kZeroKey = {};
constexpr browsing_topics::HmacKey kTestKey = {1};
constexpr browsing_topics::HmacKey kTestKey2 = {2};

constexpr int kConfigVersion = 1;
constexpr int kTaxonomyVersion = 1;
constexpr int64_t kModelVersion = 2;
constexpr size_t kPaddedTopTopicsStartIndex = 3;
constexpr base::TimeDelta kNextScheduledCalculationDelay = base::Days(7);

EpochTopics CreateTestEpochTopics(base::Time calculation_time,
                                  bool from_manually_triggered_calculation,
                                  int config_version = kConfigVersion) {
  std::vector<TopicAndDomains> top_topics_and_observing_domains;
  top_topics_and_observing_domains.emplace_back(
      TopicAndDomains(Topic(1), {HashedDomain(1)}));
  top_topics_and_observing_domains.emplace_back(
      TopicAndDomains(Topic(2), {HashedDomain(1), HashedDomain(2)}));
  top_topics_and_observing_domains.emplace_back(
      TopicAndDomains(Topic(3), {HashedDomain(1), HashedDomain(3)}));
  top_topics_and_observing_domains.emplace_back(
      TopicAndDomains(Topic(4), {HashedDomain(2), HashedDomain(3)}));
  top_topics_and_observing_domains.emplace_back(
      TopicAndDomains(Topic(5), {HashedDomain(1)}));

  EpochTopics epoch_topics(std::move(top_topics_and_observing_domains),
                           kPaddedTopTopicsStartIndex, config_version,
                           kTaxonomyVersion, kModelVersion, calculation_time,
                           from_manually_triggered_calculation);

  return epoch_topics;
}

}  // namespace

class BrowsingTopicsStateTest : public testing::Test {
 public:
  BrowsingTopicsStateTest()
      : task_environment_(new base::test::TaskEnvironment(
            base::test::TaskEnvironment::TimeSource::MOCK_TIME)) {
    // Configure a long epoch_retention_duration to prevent epochs from expiring
    // during tests where expiration is irrelevant.
    feature_list_.InitWithFeaturesAndParameters(
        /*enabled_features=*/
        {{network::features::kBrowsingTopics, {}},
         {blink::features::kBrowsingTopicsParameters,
          {{"epoch_retention_duration", "3650000d"}}}},
        /*disabled_features=*/{});

    OverrideHmacKeyForTesting(kTestKey);

    EXPECT_TRUE(temp_dir_.CreateUniqueTempDir());
  }

  base::FilePath TestFilePath() {
    return temp_dir_.GetPath().Append(FILE_PATH_LITERAL("BrowsingTopicsState"));
  }

  std::string GetTestFileContent() {
    JSONFileValueDeserializer deserializer(TestFilePath());
    std::unique_ptr<base::Value> value = deserializer.Deserialize(
        /*error_code=*/nullptr,
        /*error_message=*/nullptr);

    EXPECT_TRUE(value);
    return base::CollapseWhitespaceASCII(value->DebugString(), true);
  }

  void CreateOrOverrideTestFile(std::vector<EpochTopics> epochs,
                                base::Time next_scheduled_calculation_time,
                                std::string hex_encoded_hmac_key) {
    base::Value::List epochs_list;
    for (const EpochTopics& epoch : epochs) {
      epochs_list.Append(epoch.ToDictValue());
    }

    base::Value::Dict dict;
    dict.Set("epochs", std::move(epochs_list));
    dict.Set("next_scheduled_calculation_time",
             base::TimeToValue(next_scheduled_calculation_time));
    dict.Set("hex_encoded_hmac_key", std::move(hex_encoded_hmac_key));

    JSONFileValueSerializer(TestFilePath()).Serialize(dict);
  }

  void OnBrowsingTopicsStateLoaded() { observed_state_loaded_ = true; }

  bool observed_state_loaded() const { return observed_state_loaded_; }

 protected:
  base::test::ScopedFeatureList feature_list_;

  std::unique_ptr<base::test::TaskEnvironment> task_environment_;

  base::ScopedTempDir temp_dir_;

  bool observed_state_loaded_ = false;
};

TEST_F(BrowsingTopicsStateTest, InitFromNoFile_SaveToDiskAfterDelay) {
  base::HistogramTester histograms;

  BrowsingTopicsState state(
      temp_dir_.GetPath(),
      base::BindOnce(&BrowsingTopicsStateTest::OnBrowsingTopicsStateLoaded,
                     base::Unretained(this)));

  EXPECT_FALSE(state.HasScheduledSaveForTesting());
  EXPECT_FALSE(observed_state_loaded());

  // UMA should not be recorded yet.
  histograms.ExpectTotalCount(
      "BrowsingTopics.BrowsingTopicsState.LoadFinishStatus", 0);

  // Let the backend file read task finish.
  task_environment_->RunUntilIdle();

  histograms.ExpectUniqueSample(
      "BrowsingTopics.BrowsingTopicsState.LoadFinishStatus", true,
      /*expected_bucket_count=*/1);

  EXPECT_TRUE(state.epochs().empty());
  EXPECT_TRUE(state.next_scheduled_calculation_time().is_null());
  EXPECT_TRUE(std::ranges::equal(state.hmac_key(), kTestKey));

  EXPECT_TRUE(state.HasScheduledSaveForTesting());
  EXPECT_TRUE(observed_state_loaded());

  // Advance clock until immediately before saving takes place.
  task_environment_->FastForwardBy(base::Milliseconds(2499));
  EXPECT_TRUE(state.HasScheduledSaveForTesting());
  EXPECT_FALSE(base::PathExists(TestFilePath()));

  // Advance clock past the saving moment.
  task_environment_->FastForwardBy(base::Milliseconds(1));
  EXPECT_FALSE(state.HasScheduledSaveForTesting());
  EXPECT_TRUE(base::PathExists(TestFilePath()));
  EXPECT_EQ(
      GetTestFileContent(),
      "{\"epochs\": [ ],\"hex_encoded_hmac_key\": "
      "\"0100000000000000000000000000000000000000000000000000000000000000\","
      "\"next_scheduled_calculation_time\": \"0\"}");
}

TEST_F(BrowsingTopicsStateTest,
       UpdateNextScheduledCalculationTime_SaveToDiskAfterDelay) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());

  task_environment_->FastForwardBy(base::Milliseconds(3000));
  EXPECT_FALSE(state.HasScheduledSaveForTesting());

  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  EXPECT_TRUE(state.epochs().empty());
  EXPECT_EQ(state.next_scheduled_calculation_time(),
            base::Time::Now() + kNextScheduledCalculationDelay);
  EXPECT_TRUE(std::ranges::equal(state.hmac_key(), kTestKey));

  EXPECT_TRUE(state.HasScheduledSaveForTesting());

  task_environment_->FastForwardBy(base::Milliseconds(2499));
  EXPECT_TRUE(state.HasScheduledSaveForTesting());

  task_environment_->FastForwardBy(base::Milliseconds(1));
  EXPECT_FALSE(state.HasScheduledSaveForTesting());

  std::string expected_content = base::StrCat(
      {"{\"epochs\": [ ],\"hex_encoded_hmac_key\": "
       "\"0100000000000000000000000000000000000000000000000000000000000000"
       "\",\"next_scheduled_calculation_time\": \"",
       base::NumberToString(state.next_scheduled_calculation_time()
                                .ToDeltaSinceWindowsEpoch()
                                .InMicroseconds()),
       "\"}"});

  EXPECT_EQ(GetTestFileContent(), expected_content);
}

TEST_F(BrowsingTopicsStateTest, AddEpoch) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  // Successful topics calculation at `kTime1`.
  std::optional<EpochTopics> maybe_removed_epoch_1 =
      state.AddEpoch(CreateTestEpochTopics(
          kTime1, /*from_manually_triggered_calculation=*/false));

  EXPECT_EQ(state.epochs().size(), 1u);
  EXPECT_FALSE(state.epochs()[0].empty());
  EXPECT_EQ(state.epochs()[0].calculation_time(), kTime1);
  EXPECT_FALSE(maybe_removed_epoch_1.has_value());

  // Successful topics calculation at `kTime2`.
  std::optional<EpochTopics> maybe_removed_epoch_2 =
      state.AddEpoch(CreateTestEpochTopics(
          kTime2, /*from_manually_triggered_calculation=*/false));
  EXPECT_EQ(state.epochs().size(), 2u);
  EXPECT_FALSE(state.epochs()[0].empty());
  EXPECT_EQ(state.epochs()[0].calculation_time(), kTime1);
  EXPECT_FALSE(state.epochs()[1].empty());
  EXPECT_EQ(state.epochs()[1].calculation_time(), kTime2);
  EXPECT_FALSE(maybe_removed_epoch_2.has_value());

  // Failed topics calculation.
  std::optional<EpochTopics> maybe_removed_epoch_3 =
      state.AddEpoch(EpochTopics(kTime3));
  EXPECT_EQ(state.epochs().size(), 3u);
  EXPECT_FALSE(state.epochs()[0].empty());
  EXPECT_EQ(state.epochs()[0].calculation_time(), kTime1);
  EXPECT_FALSE(state.epochs()[1].empty());
  EXPECT_EQ(state.epochs()[1].calculation_time(), kTime2);
  EXPECT_TRUE(state.epochs()[2].empty());
  EXPECT_EQ(state.epochs()[2].calculation_time(), kTime3);
  EXPECT_FALSE(maybe_removed_epoch_3.has_value());

  // Successful topics calculation at `kTime4`.
  std::optional<EpochTopics> maybe_removed_epoch_4 =
      state.AddEpoch(CreateTestEpochTopics(
          kTime4, /*from_manually_triggered_calculation=*/false));
  EXPECT_EQ(state.epochs().size(), 4u);
  EXPECT_FALSE(state.epochs()[0].empty());
  EXPECT_EQ(state.epochs()[0].calculation_time(), kTime1);
  EXPECT_FALSE(state.epochs()[1].empty());
  EXPECT_EQ(state.epochs()[1].calculation_time(), kTime2);
  EXPECT_TRUE(state.epochs()[2].empty());
  EXPECT_FALSE(state.epochs()[3].empty());
  EXPECT_EQ(state.epochs()[3].calculation_time(), kTime4);
  EXPECT_FALSE(maybe_removed_epoch_4.has_value());

  // Successful topics calculation at `kTime5`. When this epoch is added, the
  // first one should be evicted.
  std::optional<EpochTopics> maybe_removed_epoch_5 =
      state.AddEpoch(CreateTestEpochTopics(
          kTime5, /*from_manually_triggered_calculation=*/false));
  EXPECT_EQ(state.epochs().size(), 4u);
  EXPECT_FALSE(state.epochs()[0].empty());
  EXPECT_EQ(state.epochs()[0].calculation_time(), kTime2);
  EXPECT_TRUE(state.epochs()[1].empty());
  EXPECT_FALSE(state.epochs()[2].empty());
  EXPECT_EQ(state.epochs()[2].calculation_time(), kTime4);
  EXPECT_FALSE(state.epochs()[3].empty());
  EXPECT_EQ(state.epochs()[3].calculation_time(), kTime5);
  EXPECT_TRUE(maybe_removed_epoch_5.has_value());
  EXPECT_EQ(maybe_removed_epoch_5.value().calculation_time(), kTime1);

  // The `next_scheduled_calculation_time` and `hmac_key` are unaffected.
  EXPECT_EQ(state.next_scheduled_calculation_time(), base::Time());
  EXPECT_TRUE(std::ranges::equal(state.hmac_key(), kTestKey));
}

TEST_F(BrowsingTopicsStateTest, EpochsForSite_Empty) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  EXPECT_TRUE(state.EpochsForSite(/*top_domain=*/"foo.com").empty());
}

TEST_F(BrowsingTopicsStateTest, EpochsForSite_OneEpoch_IntroductionTime) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false));
  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  ASSERT_EQ(state.CalculateSiteStickyIntroductionDelay("foo.com"),
            base::Seconds(96673));

  // Advance time to just before the epoch introduction.
  task_environment_->FastForwardBy(base::Seconds(96673));
  EXPECT_TRUE(state.EpochsForSite(/*top_domain=*/"foo.com").empty());

  // Advance time to the epoch introduction time.
  task_environment_->FastForwardBy(base::Seconds(1));
  std::vector<const EpochTopics*> epochs_for_site =
      state.EpochsForSite(/*top_domain=*/"foo.com");
  EXPECT_EQ(epochs_for_site.size(), 1u);
  EXPECT_EQ(epochs_for_site[0], &state.epochs()[0]);
}

// Together with EpochsForSite_OneEpoch_IntroductionTime, this shows that the
// epoch introduction time is influenced by the specific epoch.
TEST_F(BrowsingTopicsStateTest, EpochsForSite_OneEpoch_IntroductionTime2) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      kTime2, /*from_manually_triggered_calculation=*/false));
  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  ASSERT_EQ(state.CalculateSiteStickyIntroductionDelay("foo.com"),
            base::Seconds(151685));

  // Advance time to just before the epoch introduction.
  task_environment_->FastForwardBy(base::Seconds(151685));
  EXPECT_TRUE(state.EpochsForSite(/*top_domain=*/"foo.com").empty());

  // Advance time to the epoch introduction time.
  task_environment_->FastForwardBy(base::Seconds(1));
  std::vector<const EpochTopics*> epochs_for_site =
      state.EpochsForSite(/*top_domain=*/"foo.com");
  EXPECT_EQ(epochs_for_site.size(), 1u);
  EXPECT_EQ(epochs_for_site[0], &state.epochs()[0]);
}

TEST_F(BrowsingTopicsStateTest, EpochsForSite_OneEpoch_ManuallyTriggered) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/true));
  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  // There shouldn't be a delay when the latest epoch is manually triggered.
  ASSERT_EQ(state.CalculateSiteStickyIntroductionDelay("foo.com"),
            base::Microseconds(0));
  task_environment_->FastForwardBy(base::Microseconds(10));

  std::vector<const EpochTopics*> epochs_for_site =
      state.EpochsForSite(/*top_domain=*/"foo.com");
  EXPECT_EQ(epochs_for_site.size(), 1u);
  EXPECT_EQ(epochs_for_site[0], &state.epochs()[0]);
}

TEST_F(BrowsingTopicsStateTest, EpochsForSite_ThreeEpochs_IntroductionTime) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime2, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime3, /*from_manually_triggered_calculation=*/false));
  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  ASSERT_EQ(state.CalculateSiteStickyIntroductionDelay("foo.com"),
            base::Seconds(136778));

  // Advance time to just before the epoch introduction.
  task_environment_->FastForwardBy(base::Seconds(136778));
  std::vector<const EpochTopics*> epochs_for_site =
      state.EpochsForSite(/*top_domain=*/"foo.com");
  EXPECT_EQ(epochs_for_site.size(), 2u);
  EXPECT_EQ(epochs_for_site[0], &state.epochs()[0]);
  EXPECT_EQ(epochs_for_site[1], &state.epochs()[1]);

  // Advance time to the epoch introduction time.
  task_environment_->FastForwardBy(base::Seconds(1));

  epochs_for_site = state.EpochsForSite(/*top_domain=*/"foo.com");
  EXPECT_EQ(epochs_for_site.size(), 3u);
  EXPECT_EQ(epochs_for_site[0], &state.epochs()[0]);
  EXPECT_EQ(epochs_for_site[1], &state.epochs()[1]);
  EXPECT_EQ(epochs_for_site[2], &state.epochs()[2]);
}

TEST_F(BrowsingTopicsStateTest, EpochsForSite_PhaseOutTime) {
  feature_list_.Reset();
  feature_list_.InitWithFeaturesAndParameters(
      /*enabled_features=*/
      {{network::features::kBrowsingTopics, {}},
       {blink::features::kBrowsingTopicsParameters,
        {{"epoch_retention_duration", "28d"}}}},
      /*disabled_features=*/{});

  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  base::Time now = base::Time::Now();

  state.AddEpoch(CreateTestEpochTopics(
      now, /*from_manually_triggered_calculation=*/false));
  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  base::TimeDelta phase_out_time_offset =
      state.CalculateSiteStickyPhaseOutTimeOffset("foo.com", state.epochs()[0]);

  ASSERT_GT(phase_out_time_offset, base::Seconds(0));
  ASSERT_LT(phase_out_time_offset, base::Days(2));

  // Advance time to just before the epoch phase out.
  task_environment_->FastForwardBy(base::Days(28) - phase_out_time_offset -
                                   base::Seconds(1));

  std::vector<const EpochTopics*> epochs_for_site =
      state.EpochsForSite(/*top_domain=*/"foo.com");
  EXPECT_EQ(epochs_for_site.size(), 1u);
  EXPECT_EQ(epochs_for_site[0], &state.epochs()[0]);

  // Advance time to the epoch phase out time.
  task_environment_->FastForwardBy(base::Seconds(1));

  epochs_for_site = state.EpochsForSite(/*top_domain=*/"foo.com");
  EXPECT_EQ(epochs_for_site.size(), 0u);
}

TEST_F(BrowsingTopicsStateTest,
       EpochsForSite_ThreeEpochs_LatestManuallyTriggered) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime2, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime3, /*from_manually_triggered_calculation=*/true));
  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  task_environment_->FastForwardBy(base::Microseconds(10));

  std::vector<const EpochTopics*> epochs_for_site =
      state.EpochsForSite(/*top_domain=*/"foo.com");
  EXPECT_EQ(epochs_for_site.size(), 3u);
  EXPECT_EQ(epochs_for_site[0], &state.epochs()[0]);
  EXPECT_EQ(epochs_for_site[1], &state.epochs()[1]);
  EXPECT_EQ(epochs_for_site[2], &state.epochs()[2]);
}

TEST_F(BrowsingTopicsStateTest,
       EpochsForSite_ThreeEpochs_EarlierEpochManuallyTriggered) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime2, /*from_manually_triggered_calculation=*/true));
  state.AddEpoch(CreateTestEpochTopics(
      kTime3, /*from_manually_triggered_calculation=*/false));
  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  task_environment_->FastForwardBy(base::Microseconds(10));

  std::vector<const EpochTopics*> epochs_for_site =
      state.EpochsForSite(/*top_domain=*/"foo.com");
  // The latest epoch shouldn't be included because it wasn't manually
  // triggered.
  EXPECT_EQ(epochs_for_site.size(), 2u);
  EXPECT_EQ(epochs_for_site[0], &state.epochs()[0]);
  EXPECT_EQ(epochs_for_site[1], &state.epochs()[1]);
}

TEST_F(BrowsingTopicsStateTest,
       EpochsForSite_FourEpochs_IntroductionTimeNotArrived) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime2, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime3, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime4, /*from_manually_triggered_calculation=*/false));
  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  task_environment_->FastForwardBy(base::Hours(1));

  std::vector<const EpochTopics*> epochs_for_site =
      state.EpochsForSite(/*top_domain=*/"foo.com");
  EXPECT_EQ(epochs_for_site.size(), 3u);
  EXPECT_EQ(epochs_for_site[0], &state.epochs()[0]);
  EXPECT_EQ(epochs_for_site[1], &state.epochs()[1]);
  EXPECT_EQ(epochs_for_site[2], &state.epochs()[2]);
}

TEST_F(BrowsingTopicsStateTest,
       EpochsForSite_FourEpochs_IntroductionTimeArrived) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime2, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime3, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime4, /*from_manually_triggered_calculation=*/false));
  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  task_environment_->FastForwardBy(base::Days(1));

  std::vector<const EpochTopics*> epochs_for_site =
      state.EpochsForSite(/*top_domain=*/"foo.com");
  EXPECT_EQ(epochs_for_site.size(), 3u);
  EXPECT_EQ(epochs_for_site[0], &state.epochs()[1]);
  EXPECT_EQ(epochs_for_site[1], &state.epochs()[2]);
  EXPECT_EQ(epochs_for_site[2], &state.epochs()[3]);
}

TEST_F(BrowsingTopicsStateTest,
       EpochsForSite_FourEpochs_LatestManuallyTriggered) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime2, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime3, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime4, /*from_manually_triggered_calculation=*/true));

  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  task_environment_->FastForwardBy(base::Microseconds(10));

  std::vector<const EpochTopics*> epochs_for_site =
      state.EpochsForSite(/*top_domain=*/"foo.com");
  EXPECT_EQ(epochs_for_site.size(), 3u);
  EXPECT_EQ(epochs_for_site[0], &state.epochs()[1]);
  EXPECT_EQ(epochs_for_site[1], &state.epochs()[2]);
  EXPECT_EQ(epochs_for_site[2], &state.epochs()[3]);
}

TEST_F(BrowsingTopicsStateTest,
       EpochsForSite_FourEpochs_EarlierEpochManuallyTriggered) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime2, /*from_manually_triggered_calculation=*/true));
  state.AddEpoch(CreateTestEpochTopics(
      kTime3, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime4, /*from_manually_triggered_calculation=*/false));
  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  task_environment_->FastForwardBy(base::Microseconds(10));

  std::vector<const EpochTopics*> epochs_for_site =
      state.EpochsForSite(/*top_domain=*/"foo.com");
  // The latest epoch shouldn't be included because it wasn't manually
  // triggered.
  EXPECT_EQ(epochs_for_site.size(), 3u);
  EXPECT_EQ(epochs_for_site[0], &state.epochs()[0]);
  EXPECT_EQ(epochs_for_site[1], &state.epochs()[1]);
  EXPECT_EQ(epochs_for_site[2], &state.epochs()[2]);
}

TEST_F(BrowsingTopicsStateTest, InitFromPreexistingFile_CorruptedHmacKey) {
  base::HistogramTester histograms;

  std::vector<EpochTopics> epochs;
  epochs.emplace_back(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false));

  CreateOrOverrideTestFile(std::move(epochs),
                           /*next_scheduled_calculation_time=*/kTime2,
                           /*hex_encoded_hmac_key=*/"123");

  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  EXPECT_EQ(state.epochs().size(), 0u);
  EXPECT_TRUE(state.next_scheduled_calculation_time().is_null());
  EXPECT_TRUE(std::ranges::equal(state.hmac_key(), kZeroKey));

  histograms.ExpectUniqueSample(
      "BrowsingTopics.BrowsingTopicsState.LoadFinishStatus", false,
      /*expected_bucket_count=*/1);
}

TEST_F(BrowsingTopicsStateTest, InitFromPreexistingFile_SameConfigVersion) {
  base::HistogramTester histograms;

  std::vector<EpochTopics> epochs;
  epochs.emplace_back(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false));

  CreateOrOverrideTestFile(std::move(epochs),
                           /*next_scheduled_calculation_time=*/kTime2,
                           /*hex_encoded_hmac_key=*/base::HexEncode(kTestKey2));

  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  EXPECT_EQ(state.epochs().size(), 1u);
  EXPECT_FALSE(state.epochs()[0].empty());
  EXPECT_EQ(state.epochs()[0].model_version(), kModelVersion);
  EXPECT_EQ(state.next_scheduled_calculation_time(), kTime2);
  EXPECT_TRUE(std::ranges::equal(state.hmac_key(), kTestKey2));

  histograms.ExpectUniqueSample(
      "BrowsingTopics.BrowsingTopicsState.LoadFinishStatus", true,
      /*expected_bucket_count=*/1);
}

TEST_F(BrowsingTopicsStateTest,
       InitFromPreexistingFile_ForwardCompatibleConfigVersion) {
  base::HistogramTester histograms;

  std::vector<EpochTopics> epochs;
  // Current version is 1 but it's forward compatible with 2.
  base::test::ScopedFeatureList feature_list;
  feature_list.InitAndEnableFeatureWithParameters(
      blink::features::kBrowsingTopicsParameters,
      {{"prioritized_topics_list", ""}});
  EXPECT_EQ(CurrentConfigVersion(), 1);
  epochs.emplace_back(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false,
      /*config_version=*/2));

  CreateOrOverrideTestFile(std::move(epochs),
                           /*next_scheduled_calculation_time=*/kTime2,
                           /*hex_encoded_hmac_key=*/base::HexEncode(kTestKey2));

  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  EXPECT_EQ(state.epochs().size(), 1u);
  EXPECT_FALSE(state.epochs()[0].empty());
  EXPECT_EQ(state.epochs()[0].model_version(), kModelVersion);
  EXPECT_EQ(state.next_scheduled_calculation_time(), kTime2);
  EXPECT_TRUE(std::ranges::equal(state.hmac_key(), kTestKey2));

  histograms.ExpectUniqueSample(
      "BrowsingTopics.BrowsingTopicsState.LoadFinishStatus", true,
      /*expected_bucket_count=*/1);
}

TEST_F(BrowsingTopicsStateTest,
       InitFromPreexistingFile_BackwardCompatibleConfigVersion) {
  base::HistogramTester histograms;

  std::vector<EpochTopics> epochs;
  // Current version is 2 but it's backward compatible with 1.
  base::test::ScopedFeatureList feature_list;
  feature_list.InitAndEnableFeatureWithParameters(
      blink::features::kBrowsingTopicsParameters,
      {{"prioritized_topics_list", "4,57"}});
  EXPECT_EQ(CurrentConfigVersion(), 2);
  epochs.emplace_back(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false,
      /*config_version=*/1));

  CreateOrOverrideTestFile(std::move(epochs),
                           /*next_scheduled_calculation_time=*/kTime2,
                           /*hex_encoded_hmac_key=*/base::HexEncode(kTestKey2));

  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  EXPECT_EQ(state.epochs().size(), 1u);
  EXPECT_FALSE(state.epochs()[0].empty());
  EXPECT_EQ(state.epochs()[0].model_version(), kModelVersion);
  EXPECT_EQ(state.next_scheduled_calculation_time(), kTime2);
  EXPECT_TRUE(std::ranges::equal(state.hmac_key(), kTestKey2));

  histograms.ExpectUniqueSample(
      "BrowsingTopics.BrowsingTopicsState.LoadFinishStatus", true,
      /*expected_bucket_count=*/1);
}

TEST_F(BrowsingTopicsStateTest,
       InitFromPreexistingFile_IncompatibleConfigVersion) {
  base::HistogramTester histograms;

  std::vector<EpochTopics> epochs;
  epochs.emplace_back(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false,
      /*config_version=*/100));

  CreateOrOverrideTestFile(std::move(epochs),
                           /*next_scheduled_calculation_time=*/kTime2,
                           /*hex_encoded_hmac_key=*/base::HexEncode(kTestKey2));

  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  EXPECT_TRUE(state.epochs().empty());
  EXPECT_TRUE(state.next_scheduled_calculation_time().is_null());
  EXPECT_TRUE(std::ranges::equal(state.hmac_key(), kTestKey2));

  histograms.ExpectUniqueSample(
      "BrowsingTopics.BrowsingTopicsState.LoadFinishStatus", true,
      /*expected_bucket_count=*/1);
}

TEST_F(BrowsingTopicsStateTest, ClearOneEpoch) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false));

  EXPECT_EQ(state.epochs().size(), 1u);
  EXPECT_FALSE(state.epochs()[0].empty());
  EXPECT_EQ(state.epochs()[0].calculation_time(), kTime1);

  state.AddEpoch(CreateTestEpochTopics(
      kTime2, /*from_manually_triggered_calculation=*/false));
  EXPECT_EQ(state.epochs().size(), 2u);
  EXPECT_FALSE(state.epochs()[0].empty());
  EXPECT_EQ(state.epochs()[0].calculation_time(), kTime1);
  EXPECT_FALSE(state.epochs()[1].empty());
  EXPECT_EQ(state.epochs()[1].calculation_time(), kTime2);

  state.ClearOneEpoch(/*epoch_index=*/0);
  EXPECT_EQ(state.epochs().size(), 2u);
  EXPECT_TRUE(state.epochs()[0].empty());
  EXPECT_FALSE(state.epochs()[1].empty());
  EXPECT_EQ(state.epochs()[1].calculation_time(), kTime2);

  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  EXPECT_EQ(state.next_scheduled_calculation_time(),
            base::Time::Now() + kNextScheduledCalculationDelay);
  EXPECT_TRUE(std::ranges::equal(state.hmac_key(), kTestKey));
}

TEST_F(BrowsingTopicsStateTest, ClearAllTopics) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false));

  EXPECT_EQ(state.epochs().size(), 1u);
  EXPECT_FALSE(state.epochs()[0].empty());
  EXPECT_EQ(state.epochs()[0].calculation_time(), kTime1);

  state.AddEpoch(CreateTestEpochTopics(
      kTime2, /*from_manually_triggered_calculation=*/false));
  EXPECT_EQ(state.epochs().size(), 2u);
  EXPECT_FALSE(state.epochs()[0].empty());
  EXPECT_EQ(state.epochs()[0].calculation_time(), kTime1);
  EXPECT_FALSE(state.epochs()[1].empty());
  EXPECT_EQ(state.epochs()[1].calculation_time(), kTime2);

  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  state.ClearAllTopics();
  EXPECT_EQ(state.epochs().size(), 0u);

  EXPECT_EQ(state.next_scheduled_calculation_time(),
            base::Time::Now() + kNextScheduledCalculationDelay);
  EXPECT_TRUE(std::ranges::equal(state.hmac_key(), kTestKey));
}

TEST_F(BrowsingTopicsStateTest, ClearTopic) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime2, /*from_manually_triggered_calculation=*/false));
  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  state.ClearTopic(Topic(3));

  EXPECT_EQ(state.epochs().size(), 2u);
  EXPECT_EQ(state.epochs()[0].top_topics_and_observing_domains()[0].topic(),
            Topic(1));
  EXPECT_EQ(state.epochs()[0].top_topics_and_observing_domains()[1].topic(),
            Topic(2));
  EXPECT_FALSE(
      state.epochs()[0].top_topics_and_observing_domains()[2].IsValid());
  EXPECT_EQ(state.epochs()[0].top_topics_and_observing_domains()[3].topic(),
            Topic(4));
  EXPECT_EQ(state.epochs()[0].top_topics_and_observing_domains()[4].topic(),
            Topic(5));

  EXPECT_EQ(state.epochs()[1].top_topics_and_observing_domains()[0].topic(),
            Topic(1));
  EXPECT_EQ(state.epochs()[1].top_topics_and_observing_domains()[1].topic(),
            Topic(2));
  EXPECT_FALSE(
      state.epochs()[1].top_topics_and_observing_domains()[2].IsValid());
  EXPECT_EQ(state.epochs()[1].top_topics_and_observing_domains()[3].topic(),
            Topic(4));
  EXPECT_EQ(state.epochs()[1].top_topics_and_observing_domains()[4].topic(),
            Topic(5));
}

TEST_F(BrowsingTopicsStateTest, ClearContextDomain) {
  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      kTime1, /*from_manually_triggered_calculation=*/false));
  state.AddEpoch(CreateTestEpochTopics(
      kTime2, /*from_manually_triggered_calculation=*/false));
  state.UpdateNextScheduledCalculationTime(kNextScheduledCalculationDelay);

  state.ClearContextDomain(HashedDomain(1));

  EXPECT_EQ(
      state.epochs()[0].top_topics_and_observing_domains()[0].hashed_domains(),
      std::set<HashedDomain>{});
  EXPECT_EQ(
      state.epochs()[0].top_topics_and_observing_domains()[1].hashed_domains(),
      std::set<HashedDomain>({HashedDomain(2)}));
  EXPECT_EQ(
      state.epochs()[0].top_topics_and_observing_domains()[2].hashed_domains(),
      std::set<HashedDomain>({HashedDomain(3)}));
  EXPECT_EQ(
      state.epochs()[0].top_topics_and_observing_domains()[3].hashed_domains(),
      std::set<HashedDomain>({HashedDomain(2), HashedDomain(3)}));
  EXPECT_EQ(
      state.epochs()[0].top_topics_and_observing_domains()[4].hashed_domains(),
      std::set<HashedDomain>{});

  EXPECT_EQ(
      state.epochs()[1].top_topics_and_observing_domains()[0].hashed_domains(),
      std::set<HashedDomain>{});
  EXPECT_EQ(
      state.epochs()[1].top_topics_and_observing_domains()[1].hashed_domains(),
      std::set<HashedDomain>({HashedDomain(2)}));
  EXPECT_EQ(
      state.epochs()[1].top_topics_and_observing_domains()[2].hashed_domains(),
      std::set<HashedDomain>({HashedDomain(3)}));
  EXPECT_EQ(
      state.epochs()[1].top_topics_and_observing_domains()[3].hashed_domains(),
      std::set<HashedDomain>({HashedDomain(2), HashedDomain(3)}));
  EXPECT_EQ(
      state.epochs()[1].top_topics_and_observing_domains()[4].hashed_domains(),
      std::set<HashedDomain>{});
}

TEST_F(BrowsingTopicsStateTest, ShouldSaveFileDespiteShutdownWhileScheduled) {
  auto state = std::make_unique<BrowsingTopicsState>(temp_dir_.GetPath(),
                                                     base::DoNothing());
  task_environment_->RunUntilIdle();

  ASSERT_TRUE(state->HasScheduledSaveForTesting());
  EXPECT_FALSE(base::PathExists(TestFilePath()));

  state.reset();
  task_environment_.reset();

  // TaskEnvironment and BrowsingTopicsState both have been destroyed, mimic-ing
  // a browser shutdown.

  EXPECT_TRUE(base::PathExists(TestFilePath()));
  EXPECT_EQ(
      GetTestFileContent(),
      "{\"epochs\": [ ],\"hex_encoded_hmac_key\": "
      "\"0100000000000000000000000000000000000000000000000000000000000000\","
      "\"next_scheduled_calculation_time\": \"0\"}");
}

TEST_F(BrowsingTopicsStateTest, ScheduleEpochsExpiration) {
  feature_list_.Reset();
  feature_list_.InitWithFeaturesAndParameters(
      /*enabled_features=*/
      {{network::features::kBrowsingTopics, {}},
       {blink::features::kBrowsingTopicsParameters,
        {{"epoch_retention_duration", "28s"}}}},
      /*disabled_features=*/{});

  base::Time start_time = base::Time::Now();

  std::vector<EpochTopics> epochs;
  epochs.emplace_back(
      CreateTestEpochTopics(start_time - base::Seconds(29),
                            /*from_manually_triggered_calculation=*/false));
  epochs.emplace_back(
      CreateTestEpochTopics(start_time - base::Seconds(28),
                            /*from_manually_triggered_calculation=*/false));
  epochs.emplace_back(
      CreateTestEpochTopics(start_time - base::Seconds(27),
                            /*from_manually_triggered_calculation=*/false));
  epochs.emplace_back(
      CreateTestEpochTopics(start_time - base::Seconds(26),
                            /*from_manually_triggered_calculation=*/false));

  CreateOrOverrideTestFile(std::move(epochs),
                           /*next_scheduled_calculation_time=*/kTime2,
                           /*hex_encoded_hmac_key=*/base::HexEncode(kTestKey));

  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  EXPECT_EQ(state.epochs().size(), 4u);

  state.ScheduleEpochsExpiration();

  // Verify that two epochs have been removed immediately due to expiration.
  EXPECT_EQ(state.epochs().size(), 2u);
  EXPECT_EQ(state.epochs()[0].calculation_time(),
            start_time - base::Seconds(27));
  EXPECT_EQ(state.epochs()[1].calculation_time(),
            start_time - base::Seconds(26));

  // Process any pending tasks to ensure any asynchronous expirations are
  // handled.
  task_environment_->RunUntilIdle();
  EXPECT_EQ(state.epochs().size(), 2u);

  // Trigger another epoch expiration.
  task_environment_->FastForwardBy(base::Seconds(1));
  EXPECT_EQ(state.epochs().size(), 1u);
  EXPECT_EQ(state.epochs()[0].calculation_time(),
            start_time - base::Seconds(26));

  // Trigger the final epoch expiration.
  task_environment_->FastForwardBy(base::Seconds(1));
  EXPECT_EQ(state.epochs().size(), 0u);
}

TEST_F(BrowsingTopicsStateTest, AddEpochAndVerifyExpiration) {
  feature_list_.Reset();
  feature_list_.InitWithFeaturesAndParameters(
      /*enabled_features=*/
      {{network::features::kBrowsingTopics, {}},
       {blink::features::kBrowsingTopicsParameters,
        {{"epoch_retention_duration", "28s"}}}},
      /*disabled_features=*/{});

  base::Time start_time = base::Time::Now();

  BrowsingTopicsState state(temp_dir_.GetPath(), base::DoNothing());
  task_environment_->RunUntilIdle();

  state.AddEpoch(CreateTestEpochTopics(
      base::Time::Now(), /*from_manually_triggered_calculation=*/false));

  task_environment_->FastForwardBy(base::Seconds(1));
  state.AddEpoch(CreateTestEpochTopics(
      base::Time::Now(), /*from_manually_triggered_calculation=*/false));

  EXPECT_EQ(state.epochs().size(), 2u);

  // Verify epochs haven't expired prematurely.
  task_environment_->FastForwardBy(base::Seconds(26));
  EXPECT_EQ(state.epochs().size(), 2u);

  // Verify the first epoch expired at the expected expiration time.
  task_environment_->FastForwardBy(base::Seconds(1));
  EXPECT_EQ(state.epochs().size(), 1u);
  EXPECT_EQ(state.epochs()[0].calculation_time(),
            start_time + base::Seconds(1));

  // Verify the second epoch has also expired at the expected expiration time.
  task_environment_->FastForwardBy(base::Seconds(1));
  EXPECT_EQ(state.epochs().size(), 0u);
}

}  // namespace browsing_topics