/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";

const { Sampling } = ChromeUtils.importESModule(
  "resource://gre/modules/components-utils/Sampling.sys.mjs"
);

const { ClientEnvironment } = ChromeUtils.importESModule(
  "resource://normandy/lib/ClientEnvironment.sys.mjs"
);
const { PreferenceExperiments } = ChromeUtils.importESModule(
  "resource://normandy/lib/PreferenceExperiments.sys.mjs"
);
const { BaseAction } = ChromeUtils.importESModule(
  "resource://normandy/actions/BaseAction.sys.mjs"
);
const { PreferenceExperimentAction } = ChromeUtils.importESModule(
  "resource://normandy/actions/PreferenceExperimentAction.sys.mjs"
);
const { NormandyTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/NormandyTestUtils.sys.mjs"
);

function branchFactory(opts = {}) {
  const defaultPreferences = {
    "fake.preference": {},
  };
  const defaultPrefInfo = {
    preferenceType: "string",
    preferenceBranchType: "default",
    preferenceValue: "foo",
  };
  const preferences = {};
  for (const [prefName, prefInfo] of Object.entries(
    opts.preferences || defaultPreferences
  )) {
    preferences[prefName] = { ...defaultPrefInfo, ...prefInfo };
  }
  return {
    slug: "test",
    ratio: 1,
    ...opts,
    preferences,
  };
}

function argumentsFactory(args) {
  const defaultBranches = (args && args.branches) || [{ preferences: [] }];
  const branches = defaultBranches.map(branchFactory);
  return {
    slug: "test",
    userFacingName: "Super Cool Test Experiment",
    userFacingDescription:
      "Test experiment from browser_actions_PreferenceExperimentAction.",
    isHighPopulation: false,
    isEnrollmentPaused: false,
    ...args,
    branches,
  };
}

function prefExperimentRecipeFactory(args) {
  return recipeFactory({
    name: "preference-experiment",
    arguments: argumentsFactory(args),
  });
}

decorate_task(
  withStudiesEnabled(),
  withPrefEnv({ set: [["app.shield.optoutstudies.enabled", false]] }),
  async function checks_disabled() {
    const action = new PreferenceExperimentAction();
    action.log = mockLogger();

    const recipe = prefExperimentRecipeFactory();
    await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);

    Assert.strictEqual(action.log.debug.args.length, 1);
    Assert.deepEqual(action.log.debug.args[0], [
      "User has opted-out of opt-out experiments, disabling action.",
    ]);
    Assert.deepEqual(action.log.warn.args, [
      [
        "Skipping recipe preference-experiment because PreferenceExperimentAction " +
          "was disabled during preExecution.",
      ],
    ]);

    await action.finalize();
    Assert.strictEqual(action.log.debug.args.length, 2);
    Assert.deepEqual(action.log.debug.args[1], [
      "Skipping post-execution hook for PreferenceExperimentAction because it is disabled.",
    ]);
  }
);

decorate_task(
  withStudiesEnabled(),
  withStub(PreferenceExperiments, "start"),
  PreferenceExperiments.withMockExperiments([]),
  async function enroll_user_if_never_been_in_experiment({ startStub }) {
    const action = new PreferenceExperimentAction();
    const recipe = prefExperimentRecipeFactory({
      slug: "test",
      branches: [
        {
          slug: "branch1",
          preferences: {
            "fake.preference": {
              preferenceBranchType: "user",
              preferenceValue: "branch1",
            },
          },
          ratio: 1,
        },
        {
          slug: "branch2",
          preferences: {
            "fake.preference": {
              preferenceBranchType: "user",
              preferenceValue: "branch2",
            },
          },
          ratio: 1,
        },
      ],
    });
    sinon
      .stub(action, "chooseBranch")
      .callsFake(async function (slug, branches) {
        return branches[0];
      });

    await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
    await action.finalize();

    Assert.deepEqual(startStub.args, [
      [
        {
          slug: "test",
          actionName: "PreferenceExperimentAction",
          branch: "branch1",
          preferences: {
            "fake.preference": {
              preferenceValue: "branch1",
              preferenceBranchType: "user",
              preferenceType: "string",
            },
          },
          experimentType: "exp",
          userFacingName: "Super Cool Test Experiment",
          userFacingDescription:
            "Test experiment from browser_actions_PreferenceExperimentAction.",
        },
      ],
    ]);
  }
);

decorate_task(
  withStudiesEnabled(),
  withStub(PreferenceExperiments, "markLastSeen"),
  PreferenceExperiments.withMockExperiments([{ slug: "test", expired: false }]),
  async function markSeen_if_experiment_active({ markLastSeenStub }) {
    const action = new PreferenceExperimentAction();
    const recipe = prefExperimentRecipeFactory({
      name: "test",
    });

    await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
    await action.finalize();

    Assert.deepEqual(markLastSeenStub.args, [["test"]]);
  }
);

decorate_task(
  withStudiesEnabled(),
  withStub(PreferenceExperiments, "markLastSeen"),
  PreferenceExperiments.withMockExperiments([{ slug: "test", expired: true }]),
  async function dont_markSeen_if_experiment_expired({ markLastSeenStub }) {
    const action = new PreferenceExperimentAction();
    const recipe = prefExperimentRecipeFactory({
      name: "test",
    });

    await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
    await action.finalize();

    Assert.deepEqual(markLastSeenStub.args, [], "markLastSeen was not called");
  }
);

decorate_task(
  withStudiesEnabled(),
  withStub(PreferenceExperiments, "start"),
  async function do_nothing_if_enrollment_paused({ startStub }) {
    const action = new PreferenceExperimentAction();
    const recipe = prefExperimentRecipeFactory({
      isEnrollmentPaused: true,
    });

    await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
    await action.finalize();

    Assert.deepEqual(startStub.args, [], "start was not called");
  }
);

decorate_task(
  withStudiesEnabled(),
  withStub(PreferenceExperiments, "stop"),
  PreferenceExperiments.withMockExperiments([
    { slug: "seen", expired: false, actionName: "PreferenceExperimentAction" },
    {
      slug: "unseen",
      expired: false,
      actionName: "PreferenceExperimentAction",
    },
  ]),
  async function stop_experiments_not_seen({ stopStub }) {
    const action = new PreferenceExperimentAction();
    const recipe = prefExperimentRecipeFactory({
      slug: "seen",
    });

    await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
    await action.finalize();

    Assert.deepEqual(stopStub.args, [
      [
        "unseen",
        {
          resetValue: true,
          reason: "recipe-not-seen",
          caller: "PreferenceExperimentAction._finalize",
        },
      ],
    ]);
  }
);

decorate_task(
  withStudiesEnabled(),
  withStub(PreferenceExperiments, "stop"),
  PreferenceExperiments.withMockExperiments([
    {
      slug: "seen",
      expired: false,
      actionName: "SinglePreferenceExperimentAction",
    },
    {
      slug: "unseen",
      expired: false,
      actionName: "SinglePreferenceExperimentAction",
    },
  ]),
  async function dont_stop_experiments_for_other_action({ stopStub }) {
    const action = new PreferenceExperimentAction();
    const recipe = prefExperimentRecipeFactory({
      name: "seen",
    });

    await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
    await action.finalize();

    Assert.deepEqual(
      stopStub.args,
      [],
      "stop not called for other action's experiments"
    );
  }
);

decorate_task(
  withStudiesEnabled(),
  withStub(PreferenceExperiments, "start"),
  PreferenceExperiments.withMockExperiments([
    {
      slug: "conflict",
      preferences: {
        "conflict.pref": {},
      },
      expired: false,
    },
  ]),
  async function do_nothing_if_preference_is_already_being_tested({
    startStub,
  }) {
    const action = new PreferenceExperimentAction();
    const recipe = prefExperimentRecipeFactory({
      name: "new",
      branches: [
        {
          preferences: { "conflict.pref": {} },
        },
      ],
    });
    action.chooseBranch = sinon
      .stub()
      .callsFake(async function (slug, branches) {
        return branches[0];
      });

    await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
    await action.finalize();

    Assert.deepEqual(startStub.args, [], "start not called");
    // No way to get access to log message/Error thrown
  }
);

decorate_task(
  withStudiesEnabled(),
  withStub(PreferenceExperiments, "start"),
  PreferenceExperiments.withMockExperiments([]),
  async function experimentType_with_isHighPopulation_false({ startStub }) {
    const action = new PreferenceExperimentAction();
    const recipe = prefExperimentRecipeFactory({
      isHighPopulation: false,
    });

    await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
    await action.finalize();

    Assert.deepEqual(startStub.args[0][0].experimentType, "exp");
  }
);

decorate_task(
  withStudiesEnabled(),
  withStub(PreferenceExperiments, "start"),
  PreferenceExperiments.withMockExperiments([]),
  async function experimentType_with_isHighPopulation_true({ startStub }) {
    const action = new PreferenceExperimentAction();
    const recipe = prefExperimentRecipeFactory({
      isHighPopulation: true,
    });

    await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
    await action.finalize();

    Assert.deepEqual(startStub.args[0][0].experimentType, "exp-highpop");
  }
);

decorate_task(
  withStudiesEnabled(),
  withStub(Sampling, "ratioSample"),
  async function chooseBranch_uses_ratioSample({ ratioSampleStub }) {
    ratioSampleStub.returns(Promise.resolve(1));
    const action = new PreferenceExperimentAction();
    const branches = [
      {
        preferences: {
          "fake.preference": {
            preferenceValue: "branch0",
          },
        },
        ratio: 1,
      },
      {
        preferences: {
          "fake.preference": {
            preferenceValue: "branch1",
          },
        },
        ratio: 2,
      },
    ];
    const sandbox = sinon.createSandbox();
    let result;
    try {
      sandbox.stub(ClientEnvironment, "userId").get(() => "fake-id");
      result = await action.chooseBranch("exp-slug", branches);
    } finally {
      sandbox.restore();
    }

    Assert.deepEqual(ratioSampleStub.args, [
      ["fake-id-exp-slug-branch", [1, 2]],
    ]);
    Assert.deepEqual(result, branches[1]);
  }
);

decorate_task(
  withStudiesEnabled(),
  withMockPreferences(),
  PreferenceExperiments.withMockExperiments([]),
  async function integration_test_enroll_and_unenroll({ mockPreferences }) {
    mockPreferences.set("fake.preference", "oldvalue", "user");
    const recipe = prefExperimentRecipeFactory({
      slug: "integration test experiment",
      branches: [
        {
          slug: "branch1",
          preferences: {
            "fake.preference": {
              preferenceBranchType: "user",
              preferenceValue: "branch1",
            },
          },
          ratio: 1,
        },
        {
          slug: "branch2",
          preferences: {
            "fake.preference": {
              preferenceBranchType: "user",
              preferenceValue: "branch2",
            },
          },
          ratio: 1,
        },
      ],
      userFacingName: "userFacingName",
      userFacingDescription: "userFacingDescription",
    });

    // Session 1: we see the above recipe and enroll in the experiment.
    const action = new PreferenceExperimentAction();
    sinon
      .stub(action, "chooseBranch")
      .callsFake(async function (slug, branches) {
        return branches[0];
      });
    await action.processRecipe(recipe, BaseAction.suitability.FILTER_MATCH);
    await action.finalize();

    const activeExperiments = await PreferenceExperiments.getAllActive();
    ok(!!activeExperiments.length);
    Assert.deepEqual(activeExperiments, [
      {
        slug: "integration test experiment",
        actionName: "PreferenceExperimentAction",
        branch: "branch1",
        preferences: {
          "fake.preference": {
            preferenceBranchType: "user",
            preferenceValue: "branch1",
            preferenceType: "string",
            previousPreferenceValue: "oldvalue",
          },
        },
        expired: false,
        lastSeen: activeExperiments[0].lastSeen, // can't predict date
        experimentType: "exp",
        userFacingName: "userFacingName",
        userFacingDescription: "userFacingDescription",
      },
    ]);

    // Session 2: recipe is filtered out and so does not run.
    const action2 = new PreferenceExperimentAction();
    await action2.finalize();

    // Experiment should be unenrolled
    Assert.deepEqual(await PreferenceExperiments.getAllActive(), []);
  }
);

// Check that the appropriate set of suitabilities are considered temporary errors
decorate_task(
  withStudiesEnabled(),
  async function test_temporary_errors_set_deadline() {
    let suitabilities = [
      {
        suitability: BaseAction.suitability.SIGNATURE_ERROR,
        isTemporaryError: true,
      },
      {
        suitability: BaseAction.suitability.CAPABILITIES_MISMATCH,
        isTemporaryError: false,
      },
      {
        suitability: BaseAction.suitability.FILTER_MATCH,
        isTemporaryError: false,
      },
      {
        suitability: BaseAction.suitability.FILTER_MISMATCH,
        isTemporaryError: false,
      },
      {
        suitability: BaseAction.suitability.FILTER_ERROR,
        isTemporaryError: true,
      },
      {
        suitability: BaseAction.suitability.ARGUMENTS_INVALID,
        isTemporaryError: false,
      },
    ];

    Assert.deepEqual(
      suitabilities.map(({ suitability }) => suitability).sort(),
      Array.from(Object.values(BaseAction.suitability)).sort(),
      "This test covers all suitabilities"
    );

    // The action should set a deadline 1 week from now. To avoid intermittent
    // failures, give this a generous bound of 2 hour on either side.
    let now = Date.now();
    let hour = 60 * 60 * 1000;
    let expectedDeadline = now + 7 * 24 * hour;
    let minDeadline = new Date(expectedDeadline - 2 * hour);
    let maxDeadline = new Date(expectedDeadline + 2 * hour);

    // For each suitability, build a decorator that sets up a suitable
    // environment, and then call that decorator with a sub-test that asserts
    // the suitability is handled correctly.
    for (const { suitability, isTemporaryError } of suitabilities) {
      const decorator = PreferenceExperiments.withMockExperiments([
        { slug: `test-for-suitability-${suitability}` },
      ]);
      await decorator(async ({ prefExperiments: [experiment] }) => {
        let action = new PreferenceExperimentAction();
        let recipe = prefExperimentRecipeFactory({ slug: experiment.slug });
        await action.processRecipe(recipe, suitability);
        let modifiedExperiment = await PreferenceExperiments.get(
          experiment.slug
        );
        if (isTemporaryError) {
          is(
            typeof modifiedExperiment.temporaryErrorDeadline,
            "string",
            `A temporary failure deadline should be set as a string for suitability ${suitability}`
          );
          let deadline = new Date(modifiedExperiment.temporaryErrorDeadline);
          ok(
            deadline >= minDeadline && deadline <= maxDeadline,
            `The temporary failure deadline should be in the expected range for ` +
              `suitability ${suitability} (got ${deadline})`
          );
        } else {
          ok(
            !modifiedExperiment.temporaryErrorDeadline,
            `No temporary failure deadline should be set for suitability ${suitability}`
          );
        }
      })();
    }
  }
);

// Check that if there is an existing deadline, temporary errors don't overwrite it
decorate_task(
  withStudiesEnabled(),
  PreferenceExperiments.withMockExperiments([]),
  async function test_temporary_errors_dont_overwrite_deadline() {
    let temporaryFailureSuitabilities = [
      BaseAction.suitability.SIGNATURE_ERROR,
      BaseAction.suitability.FILTER_ERROR,
    ];

    // A deadline two hours in the future won't be hit during the test.
    let now = Date.now();
    let hour = 2 * 60 * 60 * 1000;
    let unhitDeadline = new Date(now + hour).toJSON();

    // For each suitability, build a decorator that sets up a suitabilty
    // environment, and then call that decorator with a sub-test that asserts
    // the suitability is handled correctly.
    for (const suitability of temporaryFailureSuitabilities) {
      const decorator = PreferenceExperiments.withMockExperiments([
        {
          slug: `test-for-suitability-${suitability}`,
          expired: false,
          temporaryErrorDeadline: unhitDeadline,
        },
      ]);
      await decorator(async ({ prefExperiments: [experiment] }) => {
        let action = new PreferenceExperimentAction();
        let recipe = prefExperimentRecipeFactory({ slug: experiment.slug });
        await action.processRecipe(recipe, suitability);
        let modifiedExperiment = await PreferenceExperiments.get(
          experiment.slug
        );
        is(
          modifiedExperiment.temporaryErrorDeadline,
          unhitDeadline,
          `The temporary failure deadline should not be cleared for suitability ${suitability}`
        );
      })();
    }
  }
);

// Check that if the deadline is past, temporary errors end the experiment.
decorate_task(
  withStudiesEnabled(),
  async function test_temporary_errors_hit_deadline() {
    let temporaryFailureSuitabilities = [
      BaseAction.suitability.SIGNATURE_ERROR,
      BaseAction.suitability.FILTER_ERROR,
    ];

    // Set a deadline of two hours in the past, so that the experiment expires.
    let now = Date.now();
    let hour = 2 * 60 * 60 * 1000;
    let hitDeadline = new Date(now - hour).toJSON();

    // For each suitability, build a decorator that sets up a suitabilty
    // environment, and then call that decorator with a sub-test that asserts
    // the suitability is handled correctly.
    for (const suitability of temporaryFailureSuitabilities) {
      const decorator = PreferenceExperiments.withMockExperiments([
        {
          slug: `test-for-suitability-${suitability}`,
          expired: false,
          temporaryErrorDeadline: hitDeadline,
          preferences: [],
          branch: "test-branch",
        },
      ]);
      await decorator(async ({ prefExperiments: [experiment] }) => {
        let action = new PreferenceExperimentAction();
        let recipe = prefExperimentRecipeFactory({ slug: experiment.slug });
        await action.processRecipe(recipe, suitability);
        let modifiedExperiment = await PreferenceExperiments.get(
          experiment.slug
        );
        ok(
          modifiedExperiment.expired,
          `The experiment should be expired for suitability ${suitability}`
        );
      })();
    }
  }
);

// Check that non-temporary-error suitabilities clear the temporary deadline
decorate_task(
  withStudiesEnabled(),
  PreferenceExperiments.withMockExperiments([]),
  async function test_non_temporary_error_clears_temporary_error_deadline() {
    let suitabilitiesThatShouldClearDeadline = [
      BaseAction.suitability.CAPABILITIES_MISMATCH,
      BaseAction.suitability.FILTER_MATCH,
      BaseAction.suitability.FILTER_MISMATCH,
      BaseAction.suitability.ARGUMENTS_INVALID,
    ];

    // Use a deadline in the past to demonstrate that even if the deadline has
    // passed, only a temporary error suitability ends the experiment.
    let now = Date.now();
    let hour = 60 * 60 * 1000;
    let hitDeadline = new Date(now - 2 * hour).toJSON();

    // For each suitability, build a decorator that sets up a suitabilty
    // environment, and then call that decorator with a sub-test that asserts
    // the suitability is handled correctly.
    for (const suitability of suitabilitiesThatShouldClearDeadline) {
      const decorator = PreferenceExperiments.withMockExperiments([
        NormandyTestUtils.factories.preferenceStudyFactory({
          slug: `test-for-suitability-${suitability}`.toLocaleLowerCase(),
          expired: false,
          temporaryErrorDeadline: hitDeadline,
        }),
      ]);
      await decorator(async ({ prefExperiments: [experiment] }) => {
        let action = new PreferenceExperimentAction();
        let recipe = prefExperimentRecipeFactory({ slug: experiment.slug });
        await action.processRecipe(recipe, suitability);
        let modifiedExperiment = await PreferenceExperiments.get(
          experiment.slug
        );
        ok(
          !modifiedExperiment.temporaryErrorDeadline,
          `The temporary failure deadline should be cleared for suitabilitiy ${suitability}`
        );
      })();
    }
  }
);

// Check that invalid deadlines are reset
decorate_task(
  withStudiesEnabled(),
  PreferenceExperiments.withMockExperiments([]),
  async function test_non_temporary_error_clears_temporary_error_deadline() {
    let temporaryFailureSuitabilities = [
      BaseAction.suitability.SIGNATURE_ERROR,
      BaseAction.suitability.FILTER_ERROR,
    ];

    // The action should set a deadline 1 week from now. To avoid intermittent
    // failures, give this a generous bound of 2 hours on either side.
    let invalidDeadline = "not a valid date";
    let now = Date.now();
    let hour = 60 * 60 * 1000;
    let expectedDeadline = now + 7 * 24 * hour;
    let minDeadline = new Date(expectedDeadline - 2 * hour);
    let maxDeadline = new Date(expectedDeadline + 2 * hour);

    // For each suitability, build a decorator that sets up a suitabilty
    // environment, and then call that decorator with a sub-test that asserts
    // the suitability is handled correctly.
    for (const suitability of temporaryFailureSuitabilities) {
      const decorator = PreferenceExperiments.withMockExperiments([
        NormandyTestUtils.factories.preferenceStudyFactory({
          slug: `test-for-suitability-${suitability}`.toLocaleLowerCase(),
          expired: false,
          temporaryErrorDeadline: invalidDeadline,
        }),
      ]);
      await decorator(async ({ prefExperiments: [experiment] }) => {
        let action = new PreferenceExperimentAction();
        let recipe = prefExperimentRecipeFactory({ slug: experiment.slug });
        await action.processRecipe(recipe, suitability);
        is(action.lastError, null, "No errors should be reported");
        let modifiedExperiment = await PreferenceExperiments.get(
          experiment.slug
        );
        Assert.notEqual(
          modifiedExperiment.temporaryErrorDeadline,
          invalidDeadline,
          `The temporary failure deadline should be reset for suitabilitiy ${suitability}`
        );
        let deadline = new Date(modifiedExperiment.temporaryErrorDeadline);
        ok(
          deadline >= minDeadline && deadline <= maxDeadline,
          `The temporary failure deadline should be reset to a valid deadline for ${suitability}`
        );
      })();
    }
  }
);

// Check that an already unenrolled experiment doesn't try to unenroll again if
// the filter does not match.
decorate_task(
  withStudiesEnabled(),
  withSpy(PreferenceExperiments, "stop"),
  async function test_stop_when_already_expired({ stopSpy }) {
    // Use a deadline that is already past
    const now = new Date();
    const hour = 1000 * 60 * 60;
    const temporaryErrorDeadline = new Date(now - hour * 2).toJSON();

    const suitabilitiesToCheck = Object.values(BaseAction.suitability);

    const subtest = decorate(
      PreferenceExperiments.withMockExperiments([
        NormandyTestUtils.factories.preferenceStudyFactory({
          expired: true,
          temporaryErrorDeadline,
        }),
      ]),

      async ({ prefExperiments: [experiment], suitability }) => {
        const recipe = prefExperimentRecipeFactory({ slug: experiment.slug });
        const action = new PreferenceExperimentAction();
        await action.processRecipe(recipe, suitability);
        Assert.deepEqual(
          stopSpy.args,
          [],
          `Stop should not be called for ${suitability}`
        );
      }
    );

    for (const suitability of suitabilitiesToCheck) {
      await subtest({ suitability });
    }
  }
);

// If no recipes are received, it should be considered a temporary error
decorate_task(
  withStudiesEnabled(),
  PreferenceExperiments.withMockExperiments([
    NormandyTestUtils.factories.preferenceStudyFactory({ expired: false }),
  ]),
  withSpy(PreferenceExperiments, "stop"),
  withStub(PreferenceExperimentAction.prototype, "_considerTemporaryError"),
  async function testNoRecipes({
    stopSpy,
    _considerTemporaryErrorStub,
    prefExperiments: [experiment],
  }) {
    let action = new PreferenceExperimentAction();
    await action.finalize({ noRecipes: true });

    Assert.deepEqual(stopSpy.args, [], "Stop should not be called");
    Assert.deepEqual(
      _considerTemporaryErrorStub.args,
      [[{ experiment, reason: "no-recipes" }]],
      "The experiment should accumulate a temporary error"
    );
  }
);

// If recipes are received, but the flag that none were received is set, an error should be thrown
decorate_task(
  withStudiesEnabled(),
  PreferenceExperiments.withMockExperiments([
    NormandyTestUtils.factories.preferenceStudyFactory({ expired: false }),
  ]),
  withSpy(PreferenceExperiments, "stop"),
  withStub(PreferenceExperimentAction.prototype, "_considerTemporaryError"),
  async function testNoRecipes({
    _considerTemporaryErrorStub,
    prefExperiments: [experiment],
  }) {
    const action = new PreferenceExperimentAction();
    const recipe = prefExperimentRecipeFactory({ slug: experiment.slug });
    await action.processRecipe(recipe, BaseAction.suitability.FILTER_MISMATCH);
    await action.finalize({ noRecipes: true });
    ok(
      action.lastError instanceof PreferenceExperimentAction.BadNoRecipesArg,
      "An error should be logged since some recipes were received"
    );
  }
);

// Unenrolling from an experiment where a user has changed some prefs should not override user choice
decorate_task(
  withStudiesEnabled(),
  withMockPreferences(),
  PreferenceExperiments.withMockExperiments(),
  async function testUserPrefNoReset({ mockPreferences }) {
    mockPreferences.set("test.pref.should-reset", "builtin value", "default");
    mockPreferences.set("test.pref.user-override", "builtin value", "default");

    await PreferenceExperiments.start({
      slug: "test-experiment",
      actionName: "PreferenceExperimentAction",
      isHighPopulation: false,
      isEnrollmentPaused: false,
      userFacingName: "Test Experiment",
      userFacingDescription: "Test description",
      branch: "test",
      preferences: {
        "test.pref.should-reset": {
          preferenceValue: "experiment value",
          preferenceType: "string",
          previousPreferenceValue: "builtin value",
          preferenceBranchType: "user",
          overridden: false,
        },
        "test.pref.user-override": {
          preferenceValue: "experiment value",
          preferenceType: "string",
          previousPreferenceValue: "builtin value",
          preferenceBranchType: "user",
          overridden: false,
        },
      },
    });

    mockPreferences.set("test.pref.user-override", "user value", "user");

    let exp = await PreferenceExperiments.get("test-experiment");
    is(
      exp.preferences["test.pref.user-override"].overridden,
      true,
      "Changed pref should be marked as overridden"
    );
    is(
      exp.preferences["test.pref.should-reset"].overridden,
      false,
      "Unchanged pref should not be marked as overridden"
    );

    await PreferenceExperiments.stop("test-experiment", {
      resetValue: true,
      reason: "test-reason",
    });

    is(
      Services.prefs.getCharPref("test.pref.should-reset"),
      "builtin value",
      "pref that was not overridden should reset to builtin"
    );
    is(
      Services.prefs.getCharPref("test.pref.user-override"),
      "user value",
      "pref that was overridden should keep user value"
    );
  }
);
