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

"use strict";

const { TopSites, insertPinned, DEFAULT_TOP_SITES } =
  ChromeUtils.importESModule("resource:///modules/topsites/TopSites.sys.mjs");

ChromeUtils.defineESModuleGetters(this, {
  FilterAdult: "resource:///modules/FilterAdult.sys.mjs",
  NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
  sinon: "resource://testing-common/Sinon.sys.mjs",
  PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  SearchService: "resource://gre/modules/SearchService.sys.mjs",
  TestUtils: "resource://testing-common/TestUtils.sys.mjs",
  TOP_SITES_DEFAULT_ROWS: "resource:///modules/topsites/constants.mjs",
  TOP_SITES_MAX_SITES_PER_ROW: "resource:///modules/topsites/constants.mjs",
});

const FAKE_FAVICON = "data987";
const FAKE_FAVICON_SIZE = 128;
const FAKE_FRECENCY = 200;
const FAKE_LINKS = new Array(2 * TOP_SITES_MAX_SITES_PER_ROW)
  .fill(null)
  .map((v, i) => ({
    frecency: FAKE_FRECENCY,
    url: `http://www.site${i}.com`,
  }));

function FakeTippyTopProvider() {}
FakeTippyTopProvider.prototype = {
  async init() {
    this.initialized = true;
  },
  processSite(site) {
    return site;
  },
};

let gSearchServiceInitStub;
let gGetTopSitesStub;

function stubTopSites(sandbox) {
  async function cleanup() {
    if (TopSites._refreshing) {
      info("Wait for refresh to finish.");
      // Wait for refresh to finish or else removing the store while a process
      // is running will result in errors.
      await TestUtils.topicObserved("topsites-refreshed");
      info("Top sites was refreshed.");
    }
    TopSites._tippyTopProvider.initialized = false;
    TopSites.pinnedCache.clear();
    TopSites.frecentCache.clear();
    TopSites._reset();
    stub.restore();
    info("Finished cleaning up TopSites.");
  }

  // To avoid having to setup search for each test, we stub this method and
  // unstub it when the unit test calls for the search shortcuts.
  let stub = sandbox.stub(TopSites, "updateCustomSearchShortcuts");

  TopSites._requestRichIcon = sandbox.stub();
  // Set preferences to match the store state.
  Services.prefs.setIntPref(
    "browser.newtabpage.activity-stream.topSitesRows",
    2
  );
  info("Created mock store for TopSites.");
  return cleanup;
}

function createExpectedPinnedLink(link, index) {
  link.isDefault = false;
  link.isPinned = true;
  link.searchTopSite = false;
  link.favicon = FAKE_FAVICON;
  link.faviconSize = FAKE_FAVICON_SIZE;
  link.pinIndex = index;
  return link;
}

function assertLinks(actualLinks, expectedLinks) {
  Assert.equal(
    actualLinks.length,
    expectedLinks.length,
    "Links have equal length."
  );
  for (let i = 0; i < actualLinks.length; ++i) {
    Assert.deepEqual(actualLinks[i], expectedLinks[i], "Link entry matches");
  }
}

add_setup(async () => {
  // Places requires a profile.
  do_get_profile();

  let sandbox = sinon.createSandbox();
  sandbox.stub(SearchService.prototype, "defaultEngine").get(() => {
    return { identifier: "ddg", searchUrlDomain: "duckduckgo.com" };
  });

  gGetTopSitesStub = sandbox
    .stub(NewTabUtils.activityStreamLinks, "getTopSites")
    .resolves(FAKE_LINKS);

  gSearchServiceInitStub = sandbox
    .stub(SearchService.prototype, "init")
    .resolves();

  sandbox.stub(NewTabUtils.activityStreamProvider, "_faviconBytesToDataURI");

  sandbox
    .stub(NewTabUtils.activityStreamProvider, "_addFavicons")
    .callsFake(l => {
      return Promise.resolve(
        l.map(link => {
          link.favicon = FAKE_FAVICON;
          link.faviconSize = FAKE_FAVICON_SIZE;
          return link;
        })
      );
    });

  registerCleanupFunction(() => {
    sandbox.restore();
  });
});

add_task(async function test_construction() {
  Assert.ok(TopSites._currentSearchHostname, "_currentSearchHostname defined");
});

add_task(async function test_refreshDefaults() {
  let sandbox = sinon.createSandbox();
  let cleanup = stubTopSites(sandbox);
  Assert.ok(
    !DEFAULT_TOP_SITES.length,
    "Should have 0 DEFAULT_TOP_SITES initially."
  );

  // We have to init to subscribe to changes to the preferences.
  await TopSites.init();

  info(
    "TopSites.refreshDefaults should add defaults on default.sites pref change."
  );
  Services.prefs.setStringPref(
    "browser.newtabpage.activity-stream.default.sites",
    "https://foo.com"
  );

  Assert.equal(
    DEFAULT_TOP_SITES.length,
    1,
    "Should have 1 DEFAULT_TOP_SITES now."
  );

  // Reset the DEFAULT_TOP_SITES;
  DEFAULT_TOP_SITES.length = 0;

  info("refreshDefaults should refresh on topSiteRows PREF_CHANGED");
  let refreshStub = sandbox.stub(TopSites, "refresh");
  Services.prefs.setIntPref(
    "browser.newtabpage.activity-stream.topSitesRows",
    1
  );
  Assert.ok(TopSites.refresh.calledOnce, "refresh called");
  refreshStub.restore();

  // Reset the DEFAULT_TOP_SITES;
  DEFAULT_TOP_SITES.length = 0;

  info("refreshDefaults should have default sites with .isDefault = true");
  TopSites.refreshDefaults("https://foo.com");
  Assert.equal(
    DEFAULT_TOP_SITES.length,
    1,
    "Should have a DEFAULT_TOP_SITES now."
  );
  Assert.ok(
    DEFAULT_TOP_SITES[0].isDefault,
    "Lone top site should be the default."
  );

  // Reset the DEFAULT_TOP_SITES;
  DEFAULT_TOP_SITES.length = 0;

  info("refreshDefaults should have default sites with appropriate hostname");
  TopSites.refreshDefaults("https://foo.com");
  Assert.equal(
    DEFAULT_TOP_SITES.length,
    1,
    "Should have a DEFAULT_TOP_SITES now."
  );
  let [site] = DEFAULT_TOP_SITES;
  Assert.equal(
    site.hostname,
    NewTabUtils.shortURL(site),
    "Lone top site should have the right hostname."
  );

  // Reset the DEFAULT_TOP_SITES;
  DEFAULT_TOP_SITES.length = 0;

  info("refreshDefaults should add no defaults on empty pref");
  TopSites.refreshDefaults("");
  Assert.equal(
    DEFAULT_TOP_SITES.length,
    0,
    "Should have 0 DEFAULT_TOP_SITES now."
  );

  info("refreshDefaults should be able to clear defaults");
  TopSites.refreshDefaults("https://foo.com");
  TopSites.refreshDefaults("");

  Assert.equal(
    DEFAULT_TOP_SITES.length,
    0,
    "Should have 0 DEFAULT_TOP_SITES now."
  );

  Services.prefs.clearUserPref(
    "browser.newtabpage.activity-stream.default.sites"
  );
  Services.prefs.clearUserPref(
    "browser.newtabpage.activity-stream.topSitesRows"
  );
  TopSites.uninit();
  sandbox.restore();
  await cleanup();
});

add_task(
  async function test_getLinksWithDefaults_on_SearchService_init_failure() {
    let sandbox = sinon.createSandbox();
    let cleanup = stubTopSites(sandbox);

    TopSites.refreshDefaults("https://foo.com");

    gSearchServiceInitStub.rejects(
      new Error("Simulating search init failures")
    );

    const result = await TopSites.getLinksWithDefaults();
    Assert.ok(result);

    gSearchServiceInitStub.resolves();

    sandbox.restore();
    await cleanup();
  }
);

add_task(async function test_getLinksWithDefaults() {
  NewTabUtils.activityStreamLinks.getTopSites.resetHistory();

  let sandbox = sinon.createSandbox();
  let cleanup = stubTopSites(sandbox);

  TopSites.refreshDefaults("https://foo.com");

  info("getLinksWithDefaults should get the links from NewTabUtils");
  let result = await TopSites.getLinksWithDefaults();

  const reference = FAKE_LINKS.map(site =>
    Object.assign({}, site, {
      hostname: NewTabUtils.shortURL(site),
      typedBonus: true,
    })
  );

  Assert.deepEqual(result, reference);
  Assert.ok(NewTabUtils.activityStreamLinks.getTopSites.calledOnce);

  info("getLinksWithDefaults should indicate the links get typed bonus");
  Assert.ok(result[0].typedBonus, "Expected typed bonus property to be true.");

  sandbox.restore();
  await cleanup();
});

add_task(async function test_getLinksWithDefaults_filterAdult() {
  let sandbox = sinon.createSandbox();
  info("getLinksWithDefaults should filter out non-pinned adult sites");

  sandbox.stub(FilterAdult, "filter").returns([]);
  const TEST_URL = "https://foo.com/";
  sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [{ url: TEST_URL }]);

  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults("https://foo.com");

  const result = await TopSites.getLinksWithDefaults();
  Assert.ok(FilterAdult.filter.calledOnce);
  Assert.equal(result.length, 1);
  Assert.equal(result[0].url, TEST_URL);

  sandbox.restore();
  await cleanup();
});

add_task(async function test_getLinksWithDefaults_caching() {
  let sandbox = sinon.createSandbox();

  info(
    "getLinksWithDefaults should filter out the defaults that have been blocked"
  );
  // make sure we only have one top site, and we block the only default site we have to show
  const url = "www.myonlytopsite.com";
  const topsite = {
    frecency: FAKE_FRECENCY,
    hostname: NewTabUtils.shortURL({ url }),
    typedBonus: true,
    url,
  };

  const blockedDefaultSite = { url: "https://foo.com" };
  gGetTopSitesStub.resolves([topsite]);
  sandbox.stub(NewTabUtils.blockedLinks, "isBlocked").callsFake(site => {
    return site.url === blockedDefaultSite.url;
  });

  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults("https://foo.com");
  const result = await TopSites.getLinksWithDefaults();

  // what we should be left with is just the top site we added, and not the default site we blocked
  Assert.equal(result.length, 1);
  Assert.deepEqual(result[0], topsite);
  let foundBlocked = result.find(site => site.url === blockedDefaultSite.url);
  Assert.ok(!foundBlocked, "Should not have found blocked site.");

  gGetTopSitesStub.resolves(FAKE_LINKS);
  sandbox.restore();
  await cleanup();
});

add_task(async function test_getLinksWithDefaults_dedupe() {
  let sandbox = sinon.createSandbox();

  info("getLinksWithDefaults should call dedupe.group on the links");
  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults("https://foo.com");

  let stub = sandbox.stub(TopSites.dedupe, "group").callsFake((...id) => id);
  await TopSites.getLinksWithDefaults();

  Assert.ok(stub.calledOnce, "dedupe.group was called once");
  sandbox.restore();
  await cleanup();
});

add_task(async function test__dedupe_key() {
  let sandbox = sinon.createSandbox();

  info("_dedupeKey should dedupe on hostname instead of url");
  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults("https://foo.com");

  let site = { url: "foo", hostname: "bar" };
  let result = TopSites._dedupeKey(site);

  Assert.equal(result, site.hostname, "deduped on hostname");
  sandbox.restore();
  await cleanup();
});

add_task(async function test_getLinksWithDefaults_adds_defaults() {
  let sandbox = sinon.createSandbox();

  info(
    "getLinksWithDefaults should add defaults if there are are not enough links"
  );
  const TEST_LINKS = [{ frecency: FAKE_FRECENCY, url: "foo.com" }];
  gGetTopSitesStub.resolves(TEST_LINKS);
  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults("https://foo.com");

  let result = await TopSites.getLinksWithDefaults();

  let reference = [...TEST_LINKS, ...DEFAULT_TOP_SITES].map(s =>
    Object.assign({}, s, {
      hostname: NewTabUtils.shortURL(s),
      typedBonus: true,
    })
  );

  Assert.deepEqual(result, reference);

  gGetTopSitesStub.resolves(FAKE_LINKS);
  sandbox.restore();
  await cleanup();
});

add_task(
  async function test_getLinksWithDefaults_adds_defaults_for_visible_slots() {
    let sandbox = sinon.createSandbox();

    info(
      "getLinksWithDefaults should only add defaults up to the number of visible slots"
    );
    const numVisible = TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW;
    let testLinks = [];
    for (let i = 0; i < numVisible - 1; i++) {
      testLinks.push({ frecency: FAKE_FRECENCY, url: `foo${i}.com` });
    }
    gGetTopSitesStub.resolves(testLinks);

    let cleanup = stubTopSites(sandbox);
    TopSites.refreshDefaults("https://foo.com");

    let result = await TopSites.getLinksWithDefaults();

    let reference = [...testLinks, DEFAULT_TOP_SITES[0]].map(s =>
      Object.assign({}, s, {
        hostname: NewTabUtils.shortURL(s),
        typedBonus: true,
      })
    );

    Assert.equal(result.length, numVisible);
    Assert.deepEqual(result, reference);

    gGetTopSitesStub.resolves(FAKE_LINKS);
    sandbox.restore();
    await cleanup();
  }
);

add_task(async function test_getLinksWithDefaults_no_throw_on_no_links() {
  let sandbox = sinon.createSandbox();

  info("getLinksWithDefaults should not throw if NewTabUtils returns null");
  gGetTopSitesStub.resolves(null);

  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults("https://foo.com");

  await TopSites.getLinksWithDefaults();
  Assert.ok(true, "getLinksWithDefaults did not throw");

  gGetTopSitesStub.resolves(FAKE_LINKS);
  sandbox.restore();
  await cleanup();
});

add_task(async function test_getLinksWithDefaults_get_more_on_request() {
  let sandbox = sinon.createSandbox();

  info("getLinksWithDefaults should get more if the user has asked for more");
  let testLinks = new Array(4 * TOP_SITES_MAX_SITES_PER_ROW)
    .fill(null)
    .map((v, i) => ({
      frecency: FAKE_FRECENCY,
      url: `http://www.site${i}.com`,
    }));
  gGetTopSitesStub.resolves(testLinks);

  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults("https://foo.com");

  const TEST_ROWS = 3;
  Services.prefs.setIntPref(
    "browser.newtabpage.activity-stream.topSitesRows",
    TEST_ROWS
  );

  let result = await TopSites.getLinksWithDefaults();
  Assert.equal(result.length, TEST_ROWS * TOP_SITES_MAX_SITES_PER_ROW);

  Services.prefs.clearUserPref(
    "browser.newtabpage.activity-stream.topSitesRows"
  );
  gGetTopSitesStub.resolves(FAKE_LINKS);
  sandbox.restore();
  await cleanup();
});

add_task(async function test_getLinksWithDefaults_reuse_cache() {
  let sandbox = sinon.createSandbox();
  info("getLinksWithDefaults should reuse the cache on subsequent calls");

  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults("https://foo.com");

  gGetTopSitesStub.resetHistory();

  await TopSites.getLinksWithDefaults();
  await TopSites.getLinksWithDefaults();

  Assert.ok(
    NewTabUtils.activityStreamLinks.getTopSites.calledOnce,
    "getTopSites only called once"
  );

  sandbox.restore();
  await cleanup();
});

add_task(
  async function test_getLinksWithDefaults_ignore_cache_on_requesting_more() {
    let sandbox = sinon.createSandbox();
    info("getLinksWithDefaults should ignore the cache when requesting more");

    let cleanup = stubTopSites(sandbox);
    TopSites.refreshDefaults("https://foo.com");

    gGetTopSitesStub.resetHistory();

    await TopSites.getLinksWithDefaults();
    Services.prefs.setIntPref(
      "browser.newtabpage.activity-stream.topSitesRows",
      3
    );
    await TopSites.getLinksWithDefaults();

    Assert.ok(
      NewTabUtils.activityStreamLinks.getTopSites.calledTwice,
      "getTopSites called twice"
    );

    Services.prefs.clearUserPref(
      "browser.newtabpage.activity-stream.topSitesRows"
    );
    sandbox.restore();
    await cleanup();
  }
);

add_task(
  async function test_getLinksWithDefaults_migrate_pinned_favicon_data() {
    let sandbox = sinon.createSandbox();
    info(
      "getLinksWithDefaults should migrate pinned favicon data without getting favicons again"
    );

    let cleanup = stubTopSites(sandbox);
    TopSites.refreshDefaults("https://foo.com");

    gGetTopSitesStub.resetHistory();

    sandbox
      .stub(NewTabUtils.pinnedLinks, "links")
      .get(() => [{ url: "https://foo.com/" }]);

    await TopSites.getLinksWithDefaults();

    let originalCallCount =
      NewTabUtils.activityStreamProvider._addFavicons.callCount;
    TopSites.pinnedCache.expire();

    let result = await TopSites.getLinksWithDefaults();

    Assert.equal(
      NewTabUtils.activityStreamProvider._addFavicons.callCount,
      originalCallCount,
      "_addFavicons was not called again."
    );
    Assert.equal(result[0].favicon, FAKE_FAVICON);
    Assert.equal(result[0].faviconSize, FAKE_FAVICON_SIZE);

    sandbox.restore();
    await cleanup();
  }
);

add_task(async function test_getLinksWithDefaults_no_internal_properties() {
  let sandbox = sinon.createSandbox();
  info("getLinksWithDefaults should not expose internal link properties");

  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults("https://foo.com");

  let result = await TopSites.getLinksWithDefaults();

  let internal = Object.keys(result[0]).filter(key => key.startsWith("__"));
  Assert.equal(internal.join(""), "");

  sandbox.restore();
  await cleanup();
});

add_task(async function test_getLinksWithDefaults_copy_frecent_screenshot() {
  // TopSites pulls data from NewTabUtils.activityStreamLinks.getTopSites()
  // which can still pass screenshots to it if they are available.
  let sandbox = sinon.createSandbox();
  info(
    "getLinksWithDefaults should copy the screenshot of the frecent site if it exists"
  );

  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults("https://foo.com");

  const TEST_SCREENSHOT = "screenshot";

  gGetTopSitesStub.resolves([
    { url: "https://foo.com/", screenshot: TEST_SCREENSHOT },
  ]);
  sandbox
    .stub(NewTabUtils.pinnedLinks, "links")
    .get(() => [{ url: "https://foo.com/" }]);

  let result = await TopSites.getLinksWithDefaults();

  Assert.equal(result[0].screenshot, TEST_SCREENSHOT);

  gGetTopSitesStub.resolves(FAKE_LINKS);
  sandbox.restore();
  await cleanup();
});

add_task(async function test_getLinksWithDefaults_copies_both_screenshots() {
  // TopSites pulls data from NewTabUtils.activityStreamLinks.getTopSites()
  // and NewTabUtils.pinnedLinks which can pass screenshot data to it if they
  // are available.
  let sandbox = sinon.createSandbox();
  info(
    "getLinksWithDefaults should still copy the frecent screenshot if " +
      "customScreenshotURL is set"
  );

  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults("https://foo.com");

  gGetTopSitesStub.resolves([
    { url: "https://foo.com/", screenshot: "screenshot" },
  ]);
  sandbox
    .stub(NewTabUtils.pinnedLinks, "links")
    .get(() => [{ url: "https://foo.com/", customScreenshotURL: "custom" }]);

  let result = await TopSites.getLinksWithDefaults();

  Assert.equal(result[0].screenshot, "screenshot");
  Assert.equal(result[0].customScreenshotURL, "custom");

  gGetTopSitesStub.resolves(FAKE_LINKS);
  sandbox.restore();
  await cleanup();
});

add_task(async function test_getLinksWithDefaults_persist_screenshot() {
  let sandbox = sinon.createSandbox();
  info(
    "getLinksWithDefaults should keep the same screenshot if no frecent site is found"
  );

  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults("https://foo.com");

  const CUSTOM_SCREENSHOT = "custom";

  gGetTopSitesStub.resolves([]);
  sandbox
    .stub(NewTabUtils.pinnedLinks, "links")
    .get(() => [{ url: "https://foo.com/", screenshot: CUSTOM_SCREENSHOT }]);

  let result = await TopSites.getLinksWithDefaults();

  Assert.equal(result[0].screenshot, CUSTOM_SCREENSHOT);

  gGetTopSitesStub.resolves(FAKE_LINKS);
  sandbox.restore();
  await cleanup();
});

add_task(
  async function test_getLinksWithDefaults_no_overwrite_pinned_screenshot() {
    let sandbox = sinon.createSandbox();
    info("getLinksWithDefaults should not overwrite pinned site screenshot");

    let cleanup = stubTopSites(sandbox);
    TopSites.refreshDefaults("https://foo.com");

    const EXISTING_SCREENSHOT = "some-screenshot";

    gGetTopSitesStub.resolves([{ url: "https://foo.com/", screenshot: "foo" }]);
    sandbox
      .stub(NewTabUtils.pinnedLinks, "links")
      .get(() => [
        { url: "https://foo.com/", screenshot: EXISTING_SCREENSHOT },
      ]);

    let result = await TopSites.getLinksWithDefaults();

    Assert.equal(result[0].screenshot, EXISTING_SCREENSHOT);

    gGetTopSitesStub.resolves(FAKE_LINKS);
    sandbox.restore();
    await cleanup();
  }
);

add_task(
  async function test_getLinksWithDefaults_no_searchTopSite_from_frecent() {
    let sandbox = sinon.createSandbox();
    info("getLinksWithDefaults should not set searchTopSite from frecent site");

    let cleanup = stubTopSites(sandbox);
    TopSites.refreshDefaults("https://foo.com");

    const EXISTING_SCREENSHOT = "some-screenshot";

    gGetTopSitesStub.resolves([
      {
        url: "https://foo.com/",
        searchTopSite: true,
        screenshot: EXISTING_SCREENSHOT,
      },
    ]);
    sandbox
      .stub(NewTabUtils.pinnedLinks, "links")
      .get(() => [{ url: "https://foo.com/" }]);

    let result = await TopSites.getLinksWithDefaults();

    Assert.ok(!result[0].searchTopSite);
    // But it should copy over other properties
    Assert.equal(result[0].screenshot, EXISTING_SCREENSHOT);

    gGetTopSitesStub.resolves(FAKE_LINKS);
    sandbox.restore();
    await cleanup();
  }
);

add_task(async function test_getLinksWithDefaults_concurrency_getTopSites() {
  let sandbox = sinon.createSandbox();
  info(
    "getLinksWithDefaults concurrent calls should call the backing data once"
  );

  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults("https://foo.com");

  NewTabUtils.activityStreamLinks.getTopSites.resetHistory();

  await Promise.all([
    TopSites.getLinksWithDefaults(),
    TopSites.getLinksWithDefaults(),
  ]);

  Assert.ok(
    NewTabUtils.activityStreamLinks.getTopSites.calledOnce,
    "getTopSites only called once"
  );

  sandbox.restore();
  await cleanup();
});

add_task(async function test_getLinksWithDefaults_deduping_no_dedupe_pinned() {
  let sandbox = sinon.createSandbox();
  info("getLinksWithDefaults should not dedupe pinned sites");

  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults("https://foo.com");

  sandbox
    .stub(NewTabUtils.pinnedLinks, "links")
    .get(() => [
      { url: "https://developer.mozilla.org/en-US/docs/Web" },
      { url: "https://developer.mozilla.org/en-US/docs/Learn" },
    ]);

  let sites = await TopSites.getLinksWithDefaults();
  Assert.equal(sites.length, 2 * TOP_SITES_MAX_SITES_PER_ROW);
  Assert.equal(sites[0].url, NewTabUtils.pinnedLinks.links[0].url);
  Assert.equal(sites[1].url, NewTabUtils.pinnedLinks.links[1].url);
  Assert.equal(sites[0].hostname, sites[1].hostname);

  sandbox.restore();
  await cleanup();
});

add_task(async function test_getLinksWithDefaults_prefer_pinned_sites() {
  let sandbox = sinon.createSandbox();

  info("getLinksWithDefaults should prefer pinned sites over links");

  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults();

  sandbox
    .stub(NewTabUtils.pinnedLinks, "links")
    .get(() => [
      { url: "https://developer.mozilla.org/en-US/docs/Web" },
      { url: "https://developer.mozilla.org/en-US/docs/Learn" },
    ]);

  const SECOND_TOP_SITE_URL = "https://www.mozilla.org/";

  gGetTopSitesStub.resolves([
    { frecency: FAKE_FRECENCY, url: "https://developer.mozilla.org/" },
    { frecency: FAKE_FRECENCY, url: SECOND_TOP_SITE_URL },
  ]);

  let sites = await TopSites.getLinksWithDefaults();

  // Expecting 3 links where there's 2 pinned and 1 www.mozilla.org, so
  // the frecent with matching hostname as pinned is removed.
  Assert.equal(sites.length, 3);
  Assert.equal(sites[0].url, NewTabUtils.pinnedLinks.links[0].url);
  Assert.equal(sites[1].url, NewTabUtils.pinnedLinks.links[1].url);
  Assert.equal(sites[2].url, SECOND_TOP_SITE_URL);

  gGetTopSitesStub.resolves(FAKE_LINKS);
  sandbox.restore();
  await cleanup();
});

add_task(async function test_getLinksWithDefaults_title_and_null() {
  let sandbox = sinon.createSandbox();

  info("getLinksWithDefaults should return sites that have a title");

  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults();

  sandbox
    .stub(NewTabUtils.pinnedLinks, "links")
    .get(() => [{ url: "https://github.com/mozilla/activity-stream" }]);

  let sites = await TopSites.getLinksWithDefaults();
  for (let site of sites) {
    Assert.ok(site.hostname);
  }

  info("getLinksWithDefaults should not throw for null entries");
  sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [null]);
  await TopSites.getLinksWithDefaults();
  Assert.ok(true, "getLinksWithDefaults didn't throw");

  sandbox.restore();
  await cleanup();
});

add_task(async function test_getLinksWithDefaults_calls__fetchIcon() {
  let sandbox = sinon.createSandbox();

  info("getLinksWithDefaults should return sites that have a title");

  let cleanup = stubTopSites(sandbox);
  TopSites.refreshDefaults();

  sandbox.spy(TopSites, "_fetchIcon");
  let results = await TopSites.getLinksWithDefaults();
  Assert.ok(results.length, "Got back some results");
  Assert.equal(TopSites._fetchIcon.callCount, results.length);
  for (let result of results) {
    Assert.ok(TopSites._fetchIcon.calledWith(result));
  }

  sandbox.restore();
  await cleanup();
});

add_task(async function test_init() {
  let sandbox = sinon.createSandbox();

  sandbox.stub(NimbusFeatures.newtab, "onUpdate");

  let cleanup = stubTopSites(sandbox);

  sandbox.stub(TopSites, "refresh");
  await TopSites.init();

  info("TopSites.init should call refresh");
  Assert.ok(TopSites.refresh.calledOnce, "refresh called once");
  Assert.ok(
    TopSites.refresh.calledWithExactly({
      isStartup: true,
    })
  );

  TopSites.uninit();
  sandbox.restore();
  await cleanup();
});

/**
 * If multiple callers are attempting to initializing TopSites, we should
 * initialize only once and wait until its completed.
 */
add_task(async function test_multiple_init() {
  info("Initing TopSites multiple times should call _readDefaults only once.");
  let sandbox = sinon.createSandbox();
  sandbox.stub(NimbusFeatures.newtab, "onUpdate");
  sandbox.stub(TopSites, "_readDefaults");
  let cleanup = stubTopSites(sandbox);

  Assert.ok(TopSites._readDefaults.notCalled, "Read defaults not called.");
  for (let i = 0; i < 5; ++i) {
    await TopSites.init();
  }
  Assert.ok(TopSites._readDefaults.calledOnce, "Read defaults called once.");

  sandbox.restore();
  await cleanup();
});

add_task(async function test_multiple_init_delay() {
  TopSites.uninit();

  info(
    "Initing TopSites multiple times should allow callers " +
      "only call readDefaults once and wait until its finished."
  );
  let sandbox = sinon.createSandbox();

  let resolvePromise;
  let promise = new Promise(resolve => {
    resolvePromise = resolve;
  });

  sandbox.stub(NimbusFeatures.newtab, "onUpdate");
  sandbox.stub(TopSites, "_readDefaults").returns(promise);
  let cleanup = stubTopSites(sandbox);

  Assert.ok(TopSites._readDefaults.notCalled, "Read defaults not called.");
  let finishedPromiseCount = 0;
  let promises = [];
  let callInit = async () => {
    await TopSites.init();
    ++finishedPromiseCount;
  };
  for (let i = 0; i < 5; ++i) {
    promises.push(callInit());
  }
  Assert.equal(
    finishedPromiseCount,
    0,
    "Finished promise count should be equal."
  );
  Assert.ok(TopSites._readDefaults.calledOnce, "Read defaults called once.");

  info("Resolve the promises.");
  resolvePromise();
  await Promise.all(promises);
  Assert.equal(
    finishedPromiseCount,
    5,
    "Finished promise count should be equal."
  );
  Assert.ok(
    TopSites._readDefaults.calledOnce,
    "Read defaults was still only called once."
  );

  sandbox.restore();
  await cleanup();
});

add_task(async function test_uninit() {
  info("Un-initing TopSites should expire caches.");
  let sandbox = sinon.createSandbox();

  let cleanup = stubTopSites(sandbox);
  sandbox.stub(TopSites, "refresh");
  await TopSites.init();

  sandbox.stub(TopSites.pinnedCache, "expire");
  sandbox.stub(TopSites.frecentCache, "expire");
  TopSites.uninit();

  Assert.ok(
    TopSites.pinnedCache.expire.calledOnce,
    "pinnedCache.expire called once"
  );
  Assert.ok(
    TopSites.frecentCache.expire.calledOnce,
    "frecentCache.expire called once"
  );

  sandbox.restore();
  await cleanup();
});

add_task(async function test_get_sites_init() {
  info("TopSites.getSites should initialize TopSites if its not inited.");
  let sandbox = sinon.createSandbox();

  let cleanup = stubTopSites(sandbox);
  sandbox.stub(TopSites, "init");

  Assert.ok(TopSites.init.notCalled, "TopSites.init not called.");
  await TopSites.getSites();
  Assert.ok(TopSites.init.calledOnce, "TopSites.init called once.");

  sandbox.restore();
  await cleanup();
});

add_task(async function test_get_sites_already_inited() {
  info(
    "TopSites.getSites should not call related initialization methods " +
      "more than once if TopSites is already inited."
  );
  let sandbox = sinon.createSandbox();

  let cleanup = stubTopSites(sandbox);
  sandbox.spy(TopSites, "_readDefaults");
  await TopSites.init();

  Assert.ok(
    TopSites._readDefaults.calledOnce,
    "TopSites._readDefaults called once."
  );
  Assert.ok(
    TopSites.updateCustomSearchShortcuts.calledOnce,
    "TopSites.updateCustomSearchShortcuts called once."
  );
  await TopSites.getSites();
  Assert.ok(
    TopSites._readDefaults.calledOnce,
    "TopSites._readDefaults still only called once."
  );
  Assert.ok(
    TopSites.updateCustomSearchShortcuts.calledOnce,
    "TopSites.updateCustomSearchShortcuts still only called once."
  );

  sandbox.restore();
  await cleanup();
});

add_task(async function test_get_sites_delayed_init() {
  info("TopSites.getSites should wait until initialization is done.");
  let sandbox = sinon.createSandbox();

  let cleanup = stubTopSites(sandbox);

  // Ensure it's not initialized.
  TopSites.uninit();

  let resolvePromise;
  let promise = new Promise(resolve => {
    resolvePromise = resolve;
  });
  sandbox.stub(TopSites, "init").returns(promise);

  let promises = [];
  let finishedPromiseCount = 0;
  let callGetSites = async () => {
    await TopSites.getSites();
    finishedPromiseCount += 1;
  };
  for (let i = 0; i < 5; ++i) {
    promises.push(callGetSites());
  }

  Assert.equal(
    finishedPromiseCount,
    0,
    "All calls to TopSites.getSites() haven't finished."
  );
  resolvePromise();
  await Promise.all(promises);
  Assert.equal(
    finishedPromiseCount,
    5,
    "All calls to TopSites.getSites() finished."
  );

  sandbox.restore();
  await cleanup();
});

add_task(async function test_refresh() {
  let sandbox = sinon.createSandbox();

  sandbox.stub(NimbusFeatures.newtab, "onUpdate");

  let cleanup = stubTopSites(sandbox);

  sandbox.stub(TopSites, "_fetchIcon");
  TopSites._startedUp = true;

  info("TopSites.refresh should wait for tippytop to initialize");
  TopSites._tippyTopProvider.initialized = false;
  sandbox.stub(TopSites._tippyTopProvider, "init").resolves();

  await TopSites.refresh();

  Assert.ok(
    TopSites._tippyTopProvider.init.calledOnce,
    "TopSites._tippyTopProvider.init called once"
  );

  info(
    "TopSites.refresh should not init the tippyTopProvider if already initialized"
  );
  TopSites._tippyTopProvider.initialized = true;
  TopSites._tippyTopProvider.init.resetHistory();

  await TopSites.refresh();

  Assert.ok(
    TopSites._tippyTopProvider.init.notCalled,
    "tippyTopProvider not initted again"
  );

  sandbox.restore();
  await cleanup();
});

add_task(async function test_refresh_updateTopSites() {
  // Ensure that TopSites isn't already initialized.
  TopSites.uninit();

  let sandbox = sinon.createSandbox();
  let cleanup = stubTopSites(sandbox);

  await TopSites.init();
  TopSites._reset();

  // Clear the internal store.
  TopSites._reset();

  let sites = await TopSites.getSites();
  Assert.equal(sites.length, 0, "Sites is empty.");

  info("TopSites.refresh should update TopSites.sites");
  let promise = TestUtils.topicObserved("topsites-refreshed");
  // TODO: On New Tab, subscribe to updates to Top Sites.
  await TopSites.refresh({ isStartup: true });
  await promise;

  sites = await TopSites.getSites();
  Assert.ok(sites.length, "Sites has values.");

  sandbox.restore();
  await cleanup();
});

add_task(async function test_refresh_dispatch() {
  let sandbox = sinon.createSandbox();

  info("TopSites.refresh should dispatch an action with the links returned");

  let cleanup = stubTopSites(sandbox);
  sandbox.stub(TopSites, "_fetchIcon");
  TopSites._startedUp = true;

  await TopSites.refresh();
  let reference = FAKE_LINKS.map(site =>
    Object.assign({}, site, {
      hostname: NewTabUtils.shortURL(site),
      typedBonus: true,
    })
  );

  // TODO: On New Tab, subscribe to updates to Top Sites.
  let sites = await TopSites.getSites();
  Assert.deepEqual(sites, reference, "Sites are updated.");

  sandbox.restore();
  await cleanup();
});

add_task(async function test_refresh_empty_slots() {
  let sandbox = sinon.createSandbox();

  info(
    "TopSites.refresh should handle empty slots in the resulting top sites array"
  );

  let cleanup = stubTopSites(sandbox);
  sandbox.stub(TopSites, "_fetchIcon");
  TopSites._startedUp = true;

  gGetTopSitesStub.resolves([FAKE_LINKS[0]]);
  sandbox
    .stub(NewTabUtils.pinnedLinks, "links")
    .get(() => [
      null,
      null,
      FAKE_LINKS[1],
      null,
      null,
      null,
      null,
      null,
      FAKE_LINKS[2],
    ]);
  await TopSites.refresh();

  let reference = FAKE_LINKS.map(site =>
    Object.assign({}, site, {
      hostname: NewTabUtils.shortURL(site),
      typedBonus: true,
    })
  );
  const expected = [
    reference[0],
    null,
    createExpectedPinnedLink(reference[1], 2),
    null,
    null,
    null,
    null,
    null,
    createExpectedPinnedLink(reference[2], 8),
  ];

  // TODO: On New Tab, subscribe to updates to Top Sites.
  let sites = await TopSites.getSites();
  assertLinks(sites, expected);

  gGetTopSitesStub.resolves(FAKE_LINKS);
  sandbox.restore();
  await cleanup();
});

add_task(async function test_insert_part_2() {
  let sandbox = sinon.createSandbox();

  info(
    "TopSites.handlePlacesEvents should call refresh without a target " +
      "if we clear history."
  );

  let cleanup = stubTopSites(sandbox);
  sandbox.stub(TopSites, "refresh");
  TopSites.refresh.resetHistory();
  await PlacesUtils.history.clear();
  Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once");

  TopSites.refresh.resetHistory();

  info(
    "TopSites.handlePlacesEvents should call refresh without a target " +
      "if we remove a Topsite from history"
  );
  let uri = Services.io.newURI("https://www.example.com/");
  await PlacesTestUtils.addVisits({
    uri,
    transition: PlacesUtils.history.TRANSITION_TYPED,
    visitDate: Date.now() * 1000,
  });
  Assert.ok(TopSites.refresh.notCalled, "TopSites.refresh not called");
  await PlacesUtils.history.remove(uri);

  Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once");

  info("TopSites.handlePlacesEvents should call refresh on newtab-linkBlocked");
  TopSites.refresh.resetHistory();
  // The event dispatched in NewTabUtils when a link is blocked;
  TopSites.observe(null, "newtab-linkBlocked", null);
  Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once");

  info("TopSites should call refresh on bookmark-added");
  TopSites.refresh.resetHistory();
  let bookmark = await PlacesUtils.bookmarks.insert({
    url: "https://bookmark.example.com",
    title: "Bookmark 1",
    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
  });
  Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once");

  info("TopSites should call refresh on bookmark-removed");
  TopSites.refresh.resetHistory();
  await PlacesUtils.bookmarks.remove(bookmark);
  Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once");

  info(
    "TopSites.insert should call pin with correct args " +
      "without an index specified"
  );
  sandbox.stub(NewTabUtils.pinnedLinks, "pin");

  let addAction = {
    data: { site: { url: "foo.bar", label: "foo" } },
  };
  TopSites.insert(addAction);
  Assert.ok(
    NewTabUtils.pinnedLinks.pin.calledOnce,
    "NewTabUtils.pinnedLinks.pin called once"
  );
  Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(addAction.data.site, 0));

  info("TopSites.insert should call pin with correct args");
  NewTabUtils.pinnedLinks.pin.resetHistory();
  let dropAction = {
    data: { site: { url: "foo.bar", label: "foo" }, index: 3 },
  };
  TopSites.insert(dropAction);
  Assert.ok(
    NewTabUtils.pinnedLinks.pin.calledOnce,
    "NewTabUtils.pinnedLinks.pin called once"
  );
  Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(dropAction.data.site, 3));

  sandbox.restore();
  await cleanup();
});

add_task(async function test_insert_part_1() {
  let sandbox = sinon.createSandbox();
  sandbox.stub(NewTabUtils.pinnedLinks, "pin");
  let cleanup = stubTopSites(sandbox);

  {
    info(
      "TopSites.insert should pin site in first slot of pinned list with " +
        "empty first slot"
    );

    sandbox
      .stub(NewTabUtils.pinnedLinks, "links")
      .get(() => [null, { url: "example.com" }]);
    let site = { url: "foo.bar", label: "foo" };
    await TopSites.insert({ data: { site } });
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledOnce,
      "NewTabUtils.pinnedLinks.pin called once"
    );
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0));
    NewTabUtils.pinnedLinks.pin.resetHistory();
  }

  {
    info(
      "TopSites.insert should move a pinned site in first slot to the " +
        "next slot: part 1"
    );
    let site1 = { url: "example.com" };
    sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [site1]);
    let site = { url: "foo.bar", label: "foo" };

    await TopSites.insert({ data: { site } });
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledTwice,
      "NewTabUtils.pinnedLinks.pin called twice"
    );
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0));
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 1));
    NewTabUtils.pinnedLinks.pin.resetHistory();
  }

  {
    info(
      "TopSites.insert should move a pinned site in first slot to the " +
        "next slot: part 2"
    );
    let site1 = { url: "example.com" };
    let site2 = { url: "example.org" };
    sandbox
      .stub(NewTabUtils.pinnedLinks, "links")
      .get(() => [site1, null, site2]);
    let site = { url: "foo.bar", label: "foo" };
    await TopSites.insert({ data: { site } });
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledTwice,
      "NewTabUtils.pinnedLinks.pin called twice"
    );
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0));
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 1));
    NewTabUtils.pinnedLinks.pin.resetHistory();
  }

  sandbox.restore();
  await cleanup();
});

add_task(async function test_insert_part_2() {
  let sandbox = sinon.createSandbox();
  sandbox.stub(NewTabUtils.pinnedLinks, "pin");
  let cleanup = stubTopSites(sandbox);

  {
    info(
      "TopSites.insert should unpin the last site if all slots are " +
        "already pinned"
    );
    let site1 = { url: "example.com" };
    let site2 = { url: "example.org" };
    let site3 = { url: "example.net" };
    let site4 = { url: "example.biz" };
    let site5 = { url: "example.info" };
    let site6 = { url: "example.news" };
    let site7 = { url: "example.lol" };
    let site8 = { url: "example.golf" };
    sandbox
      .stub(NewTabUtils.pinnedLinks, "links")
      .get(() => [site1, site2, site3, site4, site5, site6, site7, site8]);
    Services.prefs.setIntPref(
      "browser.newtabpage.activity-stream.topSitesRows",
      1
    );
    let site = { url: "foo.bar", label: "foo" };
    await TopSites.insert({ data: { site } });
    Assert.equal(
      NewTabUtils.pinnedLinks.pin.callCount,
      8,
      "NewTabUtils.pinnedLinks.pin called 8 times"
    );
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0));
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 1));
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site2, 2));
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site3, 3));
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site4, 4));
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site5, 5));
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site6, 6));
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site7, 7));
    NewTabUtils.pinnedLinks.pin.resetHistory();
    Services.prefs.clearUserPref(
      "browser.newtabpage.activity-stream.topSitesRows"
    );
  }

  {
    info("TopSites.insert should trigger refresh");
    sandbox.stub(TopSites, "refresh");
    let addAction = {
      data: { site: { url: "foo.com" } },
    };

    await TopSites.insert(addAction);

    Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once");
  }

  {
    info("TopSites.insert should correctly handle different index values");
    let index = -1;
    let site = { url: "foo.bar", label: "foo" };
    let action = { data: { index, site } };

    await TopSites.insert(action);
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0));

    index = undefined;
    await TopSites.insert(action);
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0));

    NewTabUtils.pinnedLinks.pin.resetHistory();
  }

  sandbox.restore();
  await cleanup();
});

add_task(async function test_insert_part_3() {
  let sandbox = sinon.createSandbox();
  sandbox.stub(NewTabUtils.pinnedLinks, "pin");
  let cleanup = stubTopSites(sandbox);

  {
    info("TopSites.insert should pin site in specified slot that is free");
    sandbox
      .stub(NewTabUtils.pinnedLinks, "links")
      .get(() => [null, { url: "example.com" }]);

    let site = { url: "foo.bar", label: "foo" };

    await TopSites.insert({ data: { index: 2, site, draggedFromIndex: 0 } });
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledOnce,
      "NewTabUtils.pinnedLinks.pin called once"
    );
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2));

    NewTabUtils.pinnedLinks.pin.resetHistory();
  }

  {
    info(
      "TopSites.insert should move a pinned site in specified slot " +
        "to the next slot"
    );
    sandbox
      .stub(NewTabUtils.pinnedLinks, "links")
      .get(() => [null, null, { url: "example.com" }]);

    let site = { url: "foo.bar", label: "foo" };

    await TopSites.insert({ data: { index: 2, site, draggedFromIndex: 3 } });
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledTwice,
      "NewTabUtils.pinnedLinks.pin called twice"
    );
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2));
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledWith({ url: "example.com" }, 3)
    );

    NewTabUtils.pinnedLinks.pin.resetHistory();
  }

  {
    info(
      "TopSites.insert should move pinned sites in the direction " +
        "of the dragged site"
    );

    let site1 = { url: "foo.bar", label: "foo" };
    let site2 = { url: "example.com", label: "example" };
    sandbox
      .stub(NewTabUtils.pinnedLinks, "links")
      .get(() => [null, null, site2]);

    await TopSites.insert({
      data: { index: 2, site: site1, draggedFromIndex: 0 },
    });
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledTwice,
      "NewTabUtils.pinnedLinks.pin called twice"
    );
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 2));
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site2, 1));
    NewTabUtils.pinnedLinks.pin.resetHistory();

    await TopSites.insert({
      data: { index: 2, site: site1, draggedFromIndex: 5 },
    });
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledTwice,
      "NewTabUtils.pinnedLinks.pin called twice"
    );
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site1, 2));
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site2, 3));
    NewTabUtils.pinnedLinks.pin.resetHistory();
  }

  {
    info("TopSites.insert should not insert past the visible top sites");
    let site1 = { url: "foo.bar", label: "foo" };
    await TopSites.insert({
      data: { index: 42, site: site1, draggedFromIndex: 0 },
    });
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.notCalled,
      "NewTabUtils.pinnedLinks.pin wasn't called"
    );

    NewTabUtils.pinnedLinks.pin.resetHistory();
  }

  sandbox.restore();
  await cleanup();
});

add_task(async function test_pin_part_1() {
  let sandbox = sinon.createSandbox();
  sandbox.stub(NewTabUtils.pinnedLinks, "pin");
  sandbox.spy(TopSites.pinnedCache, "request");
  let cleanup = stubTopSites(sandbox);

  {
    info("TopSites.pin should pin site in specified slot empty pinned list");
    let site = {
      url: "foo.bar",
      label: "foo",
    };
    Assert.ok(
      TopSites.pinnedCache.request.notCalled,
      "TopSites.pinnedCache.request not called"
    );
    await TopSites.pin({ data: { index: 2, site } });
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.called,
      "NewTabUtils.pinnedLinks.pin called"
    );
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2));
    NewTabUtils.pinnedLinks.pin.resetHistory();
    TopSites.pinnedCache.request.resetHistory();
  }

  {
    info(
      "TopSites.pin should not do a link object lookup if custom " +
        "screenshot field is not set"
    );
    let site = { url: "foo.bar", label: "foo" };
    await TopSites.pin({ data: { index: 2, site } });
    Assert.ok(
      !TopSites.pinnedCache.request.called,
      "TopSites.pinnedCache.request never called"
    );
    NewTabUtils.pinnedLinks.pin.resetHistory();
    TopSites.pinnedCache.request.resetHistory();
  }

  {
    info(
      "TopSites.pin should pin site in specified slot of pinned " +
        "list that is free"
    );
    sandbox
      .stub(NewTabUtils.pinnedLinks, "links")
      .get(() => [null, { url: "example.com" }]);

    let site = { url: "foo.bar", label: "foo" };
    await TopSites.pin({ data: { index: 2, site } });
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledOnce,
      "NewTabUtils.pinnedLinks.pin called once"
    );
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2));
    NewTabUtils.pinnedLinks.pin.resetHistory();
  }

  sandbox.restore();
  await cleanup();
});

add_task(async function test_pin_part_2() {
  let sandbox = sinon.createSandbox();
  sandbox.stub(NewTabUtils.pinnedLinks, "pin");

  {
    info("TopSites.pin should save the searchTopSite attribute if set");
    sandbox
      .stub(NewTabUtils.pinnedLinks, "links")
      .get(() => [null, { url: "example.com" }]);

    let site = { url: "foo.bar", label: "foo", searchTopSite: true };
    let cleanup = stubTopSites(sandbox);
    await TopSites.pin({ data: { index: 2, site } });
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledOnce,
      "NewTabUtils.pinnedLinks.pin called once"
    );
    Assert.ok(NewTabUtils.pinnedLinks.pin.firstCall.args[0].searchTopSite);
    NewTabUtils.pinnedLinks.pin.resetHistory();
    await cleanup();
  }

  {
    info(
      "TopSites.pin should NOT move a pinned site in specified " +
        "slot to the next slot"
    );
    sandbox
      .stub(NewTabUtils.pinnedLinks, "links")
      .get(() => [null, null, { url: "example.com" }]);

    let site = { url: "foo.bar", label: "foo" };
    let cleanup = stubTopSites(sandbox);
    await TopSites.pin({ data: { index: 2, site } });
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledOnce,
      "NewTabUtils.pinnedLinks.pin called once"
    );
    Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 2));
    NewTabUtils.pinnedLinks.pin.resetHistory();
    await cleanup();
  }

  {
    info(
      "TopSites.pin should not update LinksCache object with screenshot data" +
        "properties between migrations"
    );
    sandbox
      .stub(NewTabUtils.pinnedLinks, "links")
      .get(() => [{ url: "https://foo.com/" }]);

    let cleanup = stubTopSites(sandbox);
    let pinnedLinks = await TopSites.pinnedCache.request();
    Assert.equal(pinnedLinks.length, 1);
    TopSites.pinnedCache.expire();

    pinnedLinks[0].__sharedCache.updateLink("screenshot", "foo");

    pinnedLinks = await TopSites.pinnedCache.request();
    Assert.equal(pinnedLinks[0].screenshot, undefined);

    // Force cache expiration in order to trigger a migration of objects
    TopSites.pinnedCache.expire();
    pinnedLinks[0].__sharedCache.updateLink("screenshot", "bar");

    pinnedLinks = await TopSites.pinnedCache.request();
    Assert.equal(pinnedLinks[0].screenshot, undefined);
    await cleanup();
  }

  sandbox.restore();
});

add_task(async function test_pin_part_3() {
  let sandbox = sinon.createSandbox();
  sandbox.stub(NewTabUtils.pinnedLinks, "pin");
  sandbox.spy(TopSites, "insert");

  {
    info("TopSites.pin should call insert if index < 0");
    let site = { url: "foo.bar", label: "foo" };
    let action = { data: { index: -1, site } };
    let cleanup = stubTopSites(sandbox);
    await TopSites.pin(action);

    Assert.ok(TopSites.insert.calledOnce, "TopSites.insert called once");
    Assert.ok(TopSites.insert.calledWithExactly(action));
    NewTabUtils.pinnedLinks.pin.resetHistory();
    TopSites.insert.resetHistory();
    await cleanup();
  }

  {
    info("TopSites.pin should not call insert if index == 0");
    let site = { url: "foo.bar", label: "foo" };
    let action = { data: { index: 0, site } };
    let cleanup = stubTopSites(sandbox);
    await TopSites.pin(action);

    Assert.ok(!TopSites.insert.called, "TopSites.insert not called");
    NewTabUtils.pinnedLinks.pin.resetHistory();
    await cleanup();
  }

  {
    info("TopSites.pin should trigger refresh");
    let cleanup = stubTopSites(sandbox);
    sandbox.stub(TopSites, "refresh");
    let pinExistingAction = {
      data: { site: FAKE_LINKS[4], index: 4 },
    };

    await TopSites.pin(pinExistingAction);

    Assert.ok(TopSites.refresh.calledOnce, "TopSites.refresh called once");
    NewTabUtils.pinnedLinks.pin.resetHistory();
    await cleanup();
  }

  sandbox.restore();
});

add_task(async function test_pin_part_4() {
  let sandbox = sinon.createSandbox();
  let cleanup = stubTopSites(sandbox);

  info("TopSites.pin should call with correct parameters");
  sandbox.stub(NewTabUtils.pinnedLinks, "pin");
  sandbox.spy(TopSites, "pin");

  let pinAction = {
    data: { site: { url: "foo.com" }, index: 7 },
  };
  await TopSites.pin(pinAction);
  Assert.ok(
    NewTabUtils.pinnedLinks.pin.calledOnce,
    "NewTabUtils.pinnedLinks.pin called once"
  );
  Assert.ok(
    NewTabUtils.pinnedLinks.pin.calledWithExactly(
      pinAction.data.site,
      pinAction.data.index
    )
  );
  Assert.ok(
    TopSites.pin.calledOnce,
    "TopSites.pin should have been called once"
  );

  info(
    "TopSites.pin should unblock a previously blocked top site if " +
      "we are now adding it manually via 'Add a Top Site' option"
  );
  sandbox.stub(NewTabUtils.blockedLinks, "unblock");
  pinAction = {
    data: { site: { url: "foo.com" }, index: -1 },
  };
  await TopSites.pin(pinAction);
  Assert.ok(
    NewTabUtils.blockedLinks.unblock.calledWith({
      url: pinAction.data.site.url,
    })
  );

  info("TopSites.pin should call insert");
  sandbox.stub(TopSites, "insert");
  let addAction = {
    data: { site: { url: "foo.com" } },
  };

  await TopSites.pin(addAction);
  Assert.ok(TopSites.insert.calledOnce, "TopSites.insert called once");

  info("TopSites.unpin should call unpin with correct parameters");

  sandbox
    .stub(NewTabUtils.pinnedLinks, "links")
    .get(() => [
      null,
      null,
      { url: "foo.com" },
      null,
      null,
      null,
      null,
      null,
      FAKE_LINKS[0],
    ]);
  sandbox.stub(NewTabUtils.pinnedLinks, "unpin");

  let unpinAction = {
    data: { site: { url: "foo.com" } },
  };
  await TopSites.unpin(unpinAction);
  Assert.ok(
    NewTabUtils.pinnedLinks.unpin.calledOnce,
    "NewTabUtils.pinnedLinks.unpin called once"
  );
  Assert.ok(NewTabUtils.pinnedLinks.unpin.calledWith(unpinAction.data.site));

  sandbox.restore();
  await cleanup();
});

add_task(async function test_integration() {
  let sandbox = sinon.createSandbox();

  info("Test adding a pinned site and removing it with actions");
  let cleanup = stubTopSites(sandbox);

  TopSites._startedUp = true;

  TopSites._requestRichIcon = sandbox.stub();
  let url = "https://pin.me";
  sandbox.stub(NewTabUtils.pinnedLinks, "pin").callsFake(link => {
    NewTabUtils.pinnedLinks.links.push(link);
  });

  await TopSites.insert({ data: { site: { url } } });
  await TestUtils.topicObserved("topsites-refreshed");
  let oldSites = await TopSites.getSites();
  NewTabUtils.pinnedLinks.links.pop();
  // The event dispatched in NewTabUtils when a link is blocked;
  TopSites.observe(null, "newtab-linkBlocked", null);
  await TestUtils.topicObserved("topsites-refreshed");
  let newSites = await TopSites.getSites();

  Assert.equal(oldSites[0].url, url, "Url matches.");
  Assert.equal(newSites[0].url, FAKE_LINKS[0].url, "Url matches.");

  sandbox.restore();
  await cleanup();
});

add_task(async function test_improvesearch_noDefaultSearchTile_experiment() {
  let sandbox = sinon.createSandbox();

  sandbox.stub(SearchService.prototype, "getDefault").resolves({
    identifier: "google",
  });

  {
    info(
      "TopSites.getLinksWithDefaults should filter out alexa top 5 " +
        "search from the default sites"
    );
    let cleanup = stubTopSites(sandbox);
    Services.prefs.setBoolPref(
      "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile",
      true
    );
    let top5Test = [
      "https://google.com",
      "https://search.yahoo.com",
      "https://yahoo.com",
      "https://bing.com",
      "https://ask.com",
      "https://duckduckgo.com",
    ];

    gGetTopSitesStub.resolves([
      { url: "https://amazon.com" },
      ...top5Test.map(url => ({ url })),
    ]);

    const urlsReturned = (await TopSites.getLinksWithDefaults()).map(
      link => link.url
    );
    Assert.ok(
      urlsReturned.includes("https://amazon.com"),
      "amazon included in default links"
    );
    top5Test.forEach(url =>
      Assert.ok(!urlsReturned.includes(url), `Should not include ${url}`)
    );

    Services.prefs.clearUserPref(
      "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile"
    );
    gGetTopSitesStub.resolves(FAKE_LINKS);
    await cleanup();
  }

  {
    info(
      "TopSites.getLinksWithDefaults should not filter out alexa, default " +
        "search from the query results if the experiment pref is off"
    );
    let cleanup = stubTopSites(sandbox);
    Services.prefs.setBoolPref(
      "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile",
      false
    );
    gGetTopSitesStub.resolves([
      { url: "https://google.com" },
      { url: "https://foo.com" },
      { url: "https://duckduckgo" },
    ]);
    let urlsReturned = (await TopSites.getLinksWithDefaults()).map(
      link => link.url
    );

    Assert.ok(urlsReturned.includes("https://google.com"));
    gGetTopSitesStub.resolves(FAKE_LINKS);
    Services.prefs.clearUserPref(
      "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile"
    );
    await cleanup();
  }

  {
    info(
      "TopSites.getLinksWithDefaults should filter out the current " +
        "default search from the default sites"
    );
    let cleanup = stubTopSites(sandbox);
    Services.prefs.setBoolPref(
      "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile",
      true
    );

    sandbox.stub(TopSites, "_currentSearchHostname").get(() => "amazon");
    Services.prefs.setStringPref(
      "browser.newtabpage.activity-stream.default.sites",
      "https://google.com,https://amazon.com"
    );
    gGetTopSitesStub.resolves([{ url: "https://foo.com" }]);

    let urlsReturned = (await TopSites.getLinksWithDefaults()).map(
      link => link.url
    );
    Assert.ok(!urlsReturned.includes("https://amazon.com"));

    Services.prefs.clearUserPref(
      "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile"
    );
    Services.prefs.clearUserPref(
      "browser.newtabpage.activity-stream.default.sites"
    );
    gGetTopSitesStub.resolves(FAKE_LINKS);
    await cleanup();
  }

  {
    info(
      "TopSites.getLinksWithDefaults should not filter out current " +
        "default search from pinned sites even if it matches the current " +
        "default search"
    );
    let cleanup = stubTopSites(sandbox);
    Services.prefs.setBoolPref(
      "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile",
      true
    );

    sandbox
      .stub(NewTabUtils.pinnedLinks, "links")
      .get(() => [{ url: "google.com" }]);
    gGetTopSitesStub.resolves([{ url: "https://foo.com" }]);

    let urlsReturned = (await TopSites.getLinksWithDefaults()).map(
      link => link.url
    );
    Assert.ok(urlsReturned.includes("google.com"));

    Services.prefs.clearUserPref(
      "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile"
    );
    gGetTopSitesStub.resolves(FAKE_LINKS);
    await cleanup();
  }

  sandbox.restore();
});

add_task(
  async function test_improvesearch_noDefaultSearchTile_experiment_part_2() {
    let sandbox = sinon.createSandbox();

    sandbox.stub(SearchService.prototype, "getDefault").resolves({
      identifier: "google",
    });

    sandbox.stub(TopSites, "refresh");

    {
      info(
        "TopSites.getLinksWithDefaults should call refresh and set " +
          "._currentSearchHostname to the new engine hostname when the " +
          "default search engine has been set"
      );
      let cleanup = stubTopSites(sandbox);
      Services.prefs.setBoolPref(
        "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile",
        true
      );

      TopSites.observe(
        null,
        "browser-search-engine-modified",
        "engine-default"
      );
      Assert.equal(TopSites._currentSearchHostname, "duckduckgo");
      // Refresh is called twice:
      // 1) For the change of `noDefaultSearchTile`
      // 2) Default search engine changed "browser-search-engine-modified"
      Assert.ok(TopSites.refresh.calledTwice, "TopSites.refresh called twice");

      Services.prefs.clearUserPref(
        "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile"
      );
      gGetTopSitesStub.resolves(FAKE_LINKS);
      TopSites.refresh.resetHistory();
      await cleanup();
    }

    {
      info(
        "TopSites.getLinksWithDefaults should call refresh when the " +
          "experiment pref has changed"
      );
      let cleanup = stubTopSites(sandbox);
      Services.prefs.setBoolPref(
        "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile",
        true
      );
      Assert.ok(
        TopSites.refresh.calledOnce,
        "TopSites.refresh was called once"
      );

      Services.prefs.setBoolPref(
        "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile",
        false
      );
      Assert.ok(
        TopSites.refresh.calledTwice,
        "TopSites.refresh was called twice"
      );

      Services.prefs.clearUserPref(
        "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile"
      );
      gGetTopSitesStub.resolves(FAKE_LINKS);
      TopSites.refresh.resetHistory();
      await cleanup();
    }

    sandbox.restore();
  }
);

// eslint-disable-next-line max-statements
add_task(async function test_improvesearch_topSitesSearchShortcuts() {
  let sandbox = sinon.createSandbox();
  let searchEngines = [{ aliases: ["@google"] }, { aliases: ["@amazon"] }];
  sandbox
    .stub(SearchService.prototype, "getAppProvidedEngines")
    .resolves(searchEngines);
  sandbox.stub(NewTabUtils.pinnedLinks, "pin").callsFake((site, index) => {
    NewTabUtils.pinnedLinks.links[index] = site;
  });

  let prepTopSites = () => {
    Services.prefs.setBoolPref(
      "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts",
      true
    );
    Services.prefs.setStringPref(
      "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts.searchEngines",
      "google,amazon"
    );
    Services.prefs.setStringPref(
      "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts.havePinned",
      ""
    );
  };

  {
    info(
      "TopSites should updateCustomSearchShortcuts when experiment " +
        "pref is turned on"
    );
    let cleanup = stubTopSites(sandbox);
    prepTopSites();
    Services.prefs.setBoolPref(
      "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts",
      false
    );
    // stubTopSites stubs updateCustomSearchShortcuts, when we need to add
    // a spy.
    TopSites.updateCustomSearchShortcuts.restore();
    sandbox.spy(TopSites, "updateCustomSearchShortcuts");

    // turn the experiment on
    Services.prefs.setBoolPref(
      "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts",
      true
    );

    Assert.ok(
      TopSites.updateCustomSearchShortcuts.calledOnce,
      "TopSites.updateCustomSearchShortcuts called once"
    );
    Services.prefs.clearUserPref(
      "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts"
    );
    TopSites.updateCustomSearchShortcuts.restore();
    await cleanup();
  }

  {
    info(
      "TopSites should filter out default top sites that match a " +
        "hostname of a search shortcut if previously blocked"
    );
    let cleanup = stubTopSites(sandbox);
    prepTopSites();
    TopSites.refreshDefaults("https://amazon.ca");
    sandbox
      .stub(NewTabUtils.blockedLinks, "links")
      .value([{ url: "https://amazon.com" }]);
    sandbox.stub(NewTabUtils.blockedLinks, "isBlocked").callsFake(site => {
      return NewTabUtils.blockedLinks.links[0].url === site.url;
    });

    let urlsReturned = (await TopSites.getLinksWithDefaults()).map(
      link => link.url
    );
    Assert.ok(!urlsReturned.includes("https://amazon.ca"));
    await cleanup();
  }

  {
    info("TopSites should update frecent search topsite icon");
    let cleanup = stubTopSites(sandbox);
    prepTopSites();
    sandbox.stub(TopSites._tippyTopProvider, "processSite").callsFake(site => {
      site.tippyTopIcon = "icon.png";
      site.backgroundColor = "#fff";
      return site;
    });
    gGetTopSitesStub.resolves([{ url: "https://google.com" }]);

    let urlsReturned = await TopSites.getLinksWithDefaults();

    let defaultSearchTopsite = urlsReturned.find(
      s => s.url === "https://google.com"
    );
    Assert.ok(defaultSearchTopsite.searchTopSite);
    Assert.equal(defaultSearchTopsite.tippyTopIcon, "icon.png");
    Assert.equal(defaultSearchTopsite.backgroundColor, "#fff");
    gGetTopSitesStub.resolves(FAKE_LINKS);
    TopSites._tippyTopProvider.processSite.restore();
    await cleanup();
  }

  {
    info("TopSites should update default search topsite icon");
    let cleanup = stubTopSites(sandbox);
    prepTopSites();
    sandbox.stub(TopSites._tippyTopProvider, "processSite").callsFake(site => {
      site.tippyTopIcon = "icon.png";
      site.backgroundColor = "#fff";
      return site;
    });
    gGetTopSitesStub.resolves([{ url: "https://foo.com" }]);

    let urlsReturned = await TopSites.getLinksWithDefaults();

    let defaultSearchTopsite = urlsReturned.find(
      s => s.url === "https://amazon.com"
    );
    Assert.ok(defaultSearchTopsite.searchTopSite);
    Assert.equal(defaultSearchTopsite.tippyTopIcon, "icon.png");
    Assert.equal(defaultSearchTopsite.backgroundColor, "#fff");
    gGetTopSitesStub.resolves(FAKE_LINKS);
    TopSites._tippyTopProvider.processSite.restore();
    await cleanup();
  }

  {
    info(
      "TopSites should dispatch UPDATE_SEARCH_SHORTCUTS on " +
        "updateCustomSearchShortcuts"
    );
    let cleanup = stubTopSites(sandbox);
    prepTopSites();
    Services.prefs.setBoolPref(
      "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile",
      true
    );
    // stubTopSites stubs updateCustomSearchShortcuts, when in this case, we
    // want to check the effect of the method.
    TopSites.updateCustomSearchShortcuts.restore();
    await TopSites.updateCustomSearchShortcuts();
    let searchShortcuts = await TopSites.getSearchShortcuts();
    Assert.deepEqual(searchShortcuts, [
      {
        keyword: "@google",
        shortURL: "google",
        url: "https://google.com",
        backgroundColor: undefined,
        smallFavicon:
          "chrome://activity-stream/content/data/content/tippytop/favicons/google-com.ico",
        tippyTopIcon:
          "chrome://activity-stream/content/data/content/tippytop/images/google-com@2x.png",
      },
      {
        keyword: "@amazon",
        shortURL: "amazon",
        url: "https://amazon.com",
        backgroundColor: undefined,
        smallFavicon:
          "chrome://activity-stream/content/data/content/tippytop/favicons/amazon.ico",
        tippyTopIcon:
          "chrome://activity-stream/content/data/content/tippytop/images/amazon@2x.png",
      },
    ]);
    await cleanup();
  }

  {
    info(
      "TopSites should refresh when top sites search shortcut feature gate pref changes."
    );
    let cleanup = stubTopSites(sandbox);
    prepTopSites();

    let promise = TestUtils.topicObserved("topsites-refreshed");
    Services.prefs.setBoolPref(
      "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts",
      false
    );
    await promise;

    let sites = await TopSites.getSites();
    let searchTopSiteCount = sites.reduce(
      (acc, current) => (current.searchTopSite ? 1 : 0 + acc),
      0
    );
    Assert.equal(searchTopSiteCount, 0, "Number of search top sites.");

    promise = TestUtils.topicObserved("topsites-refreshed");
    Services.prefs.setBoolPref(
      "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts",
      true
    );
    await promise;
    sites = await TopSites.getSites();
    searchTopSiteCount = sites.reduce(
      (acc, current) => acc + (current.searchTopSite ? 1 : 0),
      0
    );
    Assert.equal(searchTopSiteCount, 2, "Number of search top sites.");
    await cleanup();
  }

  Services.prefs.clearUserPref(
    "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts"
  );
  Services.prefs.clearUserPref(
    "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts.searchEngines"
  );
  Services.prefs.clearUserPref(
    "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts.havePinned"
  );
  sandbox.restore();
});

add_task(async function test_updatePinnedSearchShortcuts() {
  let sandbox = sinon.createSandbox();
  sandbox.stub(NewTabUtils.pinnedLinks, "pin");
  sandbox.stub(NewTabUtils.pinnedLinks, "unpin");

  {
    info(
      "TopSites.updatePinnedSearchShortcuts should unpin a " +
        "shortcut in deletedShortcuts"
    );
    let cleanup = stubTopSites(sandbox);

    let deletedShortcuts = [
      {
        url: "https://google.com",
        searchVendor: "google",
        label: "google",
        searchTopSite: true,
      },
    ];
    let addedShortcuts = [];
    sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [
      null,
      null,
      {
        url: "https://amazon.com",
        searchVendor: "amazon",
        label: "amazon",
        searchTopSite: true,
      },
    ]);
    TopSites.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts });
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.notCalled,
      "NewTabUtils.pinnedLinks.pin not called"
    );
    Assert.ok(
      NewTabUtils.pinnedLinks.unpin.calledOnce,
      "NewTabUtils.pinnedLinks.unpin called once"
    );
    Assert.ok(
      NewTabUtils.pinnedLinks.unpin.calledWith({
        url: "https://google.com",
      })
    );

    NewTabUtils.pinnedLinks.pin.resetHistory();
    NewTabUtils.pinnedLinks.unpin.resetHistory();
    await cleanup();
  }

  {
    info(
      "TopSites.updatePinnedSearchShortcuts should pin a shortcut " +
        "in addedShortcuts"
    );
    let cleanup = stubTopSites(sandbox);

    let addedShortcuts = [
      {
        url: "https://google.com",
        searchVendor: "google",
        label: "google",
        searchTopSite: true,
      },
    ];
    let deletedShortcuts = [];
    sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [
      null,
      null,
      {
        url: "https://amazon.com",
        searchVendor: "amazon",
        label: "amazon",
        searchTopSite: true,
      },
    ]);
    TopSites.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts });
    Assert.ok(
      NewTabUtils.pinnedLinks.unpin.notCalled,
      "NewTabUtils.pinnedLinks.unpin not called"
    );
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledOnce,
      "NewTabUtils.pinnedLinks.pin called once"
    );
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledWith(
        {
          label: "google",
          searchTopSite: true,
          searchVendor: "google",
          url: "https://google.com",
        },
        0
      )
    );

    NewTabUtils.pinnedLinks.pin.resetHistory();
    NewTabUtils.pinnedLinks.unpin.resetHistory();
    await cleanup();
  }

  {
    info(
      "TopSites.updatePinnedSearchShortcuts should pin and unpin " +
        "in the same action"
    );
    let cleanup = stubTopSites(sandbox);

    let addedShortcuts = [
      {
        url: "https://google.com",
        searchVendor: "google",
        label: "google",
        searchTopSite: true,
      },
      {
        url: "https://ebay.com",
        searchVendor: "ebay",
        label: "ebay",
        searchTopSite: true,
      },
    ];
    let deletedShortcuts = [
      {
        url: "https://amazon.com",
        searchVendor: "amazon",
        label: "amazon",
        searchTopSite: true,
      },
    ];

    sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => [
      { url: "https://foo.com" },
      {
        url: "https://amazon.com",
        searchVendor: "amazon",
        label: "amazon",
        searchTopSite: true,
      },
    ]);
    TopSites.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts });

    Assert.ok(
      NewTabUtils.pinnedLinks.unpin.calledOnce,
      "NewTabUtils.pinnedLinks.unpin called once"
    );
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledTwice,
      "NewTabUtils.pinnedLinks.pin called twice"
    );

    NewTabUtils.pinnedLinks.pin.resetHistory();
    NewTabUtils.pinnedLinks.unpin.resetHistory();
    await cleanup();
  }

  {
    info(
      "TopSites.updatePinnedSearchShortcuts should pin a shortcut in " +
        "addedShortcuts even if pinnedLinks is full"
    );
    let cleanup = stubTopSites(sandbox);

    let addedShortcuts = [
      {
        url: "https://google.com",
        searchVendor: "google",
        label: "google",
        searchTopSite: true,
      },
    ];
    let deletedShortcuts = [];
    sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => FAKE_LINKS);
    TopSites.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts });

    Assert.ok(
      NewTabUtils.pinnedLinks.unpin.notCalled,
      "NewTabUtils.pinnedLinks.unpin not called"
    );
    Assert.ok(
      NewTabUtils.pinnedLinks.pin.calledWith(
        { label: "google", searchTopSite: true, url: "https://google.com" },
        0
      ),
      "NewTabUtils.pinnedLinks.unpin not called"
    );

    NewTabUtils.pinnedLinks.pin.resetHistory();
    NewTabUtils.pinnedLinks.unpin.resetHistory();
    await cleanup();
  }

  sandbox.restore();
});

add_task(async function test_insertPinned() {
  info("#insertPinned");

  function createLinks(count) {
    return new Array(count).fill(null).map((v, i) => ({ url: `site${i}.com` }));
  }

  info("should place pinned links where they belong");
  {
    let links = createLinks(12);
    const pinned = [
      { url: "http://github.com/mozilla/activity-stream", title: "moz/a-s" },
      { url: "http://example.com", title: "example" },
    ];

    const result = insertPinned(links, pinned);
    for (let index of [0, 1]) {
      Assert.equal(result[index].url, pinned[index].url, "Pinned URL matches");
      Assert.ok(result[index].isPinned, "Link is marked as pinned");
      Assert.equal(result[index].pinIndex, index, "Pin index is correct");
    }
    Assert.deepEqual(result.slice(2), links, "Remaining links are unchanged");
  }

  info("should handle empty slots in the pinned list");
  {
    let links = createLinks(12);
    const pinned = [
      null,
      { url: "http://github.com/mozilla/activity-stream", title: "moz/a-s" },
      null,
      null,
      { url: "http://example.com", title: "example" },
    ];

    const result = insertPinned(links, pinned);
    for (let index of [1, 4]) {
      Assert.equal(result[index].url, pinned[index].url, "Pinned URL matches");
      Assert.ok(result[index].isPinned, "Link is marked as pinned");
      Assert.equal(result[index].pinIndex, index, "Pin index is correct");
    }
    result.splice(4, 1);
    result.splice(1, 1);
    Assert.deepEqual(result, links, "Remaining links are unchanged");
  }

  info("should handle a pinned site past the end of the list of links");
  {
    const pinned = [];
    pinned[11] = {
      url: "http://github.com/mozilla/activity-stream",
      title: "moz/a-s",
    };

    const result = insertPinned([], pinned);
    Assert.equal(result[11].url, pinned[11].url, "Pinned URL matches");
    Assert.ok(result[11].isPinned, "Link is marked as pinned");
    Assert.equal(result[11].pinIndex, 11, "Pin index is correct");
  }

  info("should unpin previously pinned links no longer in the pinned list");
  {
    let links = createLinks(12);
    const pinned = [];
    links[2].isPinned = true;
    links[2].pinIndex = 2;

    const result = insertPinned(links, pinned);
    Assert.ok(!result[2].isPinned, "isPinned property removed");
    Assert.ok(!result[2].pinIndex, "pinIndex property removed");
  }

  info("should handle a link present in both the links and pinned list");
  {
    let links = createLinks(12);
    const pinned = [links[7]];

    const result = insertPinned(links, pinned);
    Assert.equal(links.length, result.length, "Length of links is unchanged");
  }

  info("should not modify the original data");
  {
    let links = createLinks(12);
    const pinned = [{ url: "http://example.com" }];

    insertPinned(links, pinned);

    Assert.equal(
      typeof pinned[0].isPinned,
      "undefined",
      "Pinned data is not mutated"
    );
  }
});
