import { FAKE_GLOBAL_PREFS, FakePrefs, GlobalOverrider } from "test/unit/utils";
import { actionTypes as at } from "common/Actions.sys.mjs";
import injector from "inject!lib/TopStoriesFeed.jsm";

describe("Top Stories Feed", () => {
  let TopStoriesFeed;
  let STORIES_UPDATE_TIME;
  let TOPICS_UPDATE_TIME;
  let SECTION_ID;
  let SPOC_IMPRESSION_TRACKING_PREF;
  let REC_IMPRESSION_TRACKING_PREF;
  let DEFAULT_RECS_EXPIRE_TIME;
  let instance;
  let clock;
  let globals;
  let sectionsManagerStub;
  let shortURLStub;

  const FAKE_OPTIONS = {
    stories_endpoint: "https://somedomain.org/stories?key=$apiKey",
    stories_referrer: "https://somedomain.org/referrer",
    topics_endpoint: "https://somedomain.org/topics?key=$apiKey",
    survey_link: "https://www.surveymonkey.com/r/newtabffx",
    api_key_pref: "apiKeyPref",
    provider_name: "test-provider",
    provider_icon: "provider-icon",
    provider_description: "provider_desc",
  };

  beforeEach(() => {
    FAKE_GLOBAL_PREFS.set("apiKeyPref", "test-api-key");
    FAKE_GLOBAL_PREFS.set(
      "pocketCta",
      JSON.stringify({
        cta_button: "",
        cta_text: "",
        cta_url: "",
        use_cta: false,
      })
    );

    globals = new GlobalOverrider();
    globals.set("PlacesUtils", { history: {} });
    globals.set("pktApi", { isUserLoggedIn() {} });
    clock = sinon.useFakeTimers();
    shortURLStub = sinon.stub().callsFake(site => site.url);
    sectionsManagerStub = {
      onceInitialized: sinon.stub().callsFake(callback => callback()),
      enableSection: sinon.spy(),
      disableSection: sinon.spy(),
      updateSection: sinon.spy(),
      sections: new Map([["topstories", { options: FAKE_OPTIONS }]]),
    };

    ({
      TopStoriesFeed,
      STORIES_UPDATE_TIME,
      TOPICS_UPDATE_TIME,
      SECTION_ID,
      SPOC_IMPRESSION_TRACKING_PREF,
      REC_IMPRESSION_TRACKING_PREF,
      DEFAULT_RECS_EXPIRE_TIME,
    } = injector({
      "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs },
      "lib/ShortURL.jsm": { shortURL: shortURLStub },
      "lib/SectionsManager.jsm": { SectionsManager: sectionsManagerStub },
    }));

    instance = new TopStoriesFeed();
    instance.store = {
      getState() {
        return {
          Prefs: {
            values: {
              showSponsored: true,
              "feeds.section.topstories": true,
            },
          },
        };
      },
      dispatch: sinon.spy(),
    };
    instance.storiesLastUpdated = 0;
    instance.topicsLastUpdated = 0;
  });
  afterEach(() => {
    globals.restore();
    clock.restore();
  });

  describe("#lazyloading TopStories", () => {
    beforeEach(() => {
      instance.discoveryStreamEnabled = true;
    });
    it("should bind parseOptions to SectionsManager.onceInitialized when discovery stream is true", () => {
      instance.discoveryStreamEnabled = false;
      instance.store.getState = () => ({
        Prefs: {
          values: {
            "discoverystream.config": JSON.stringify({ enabled: true }),
            "feeds.section.topstories": true,
          },
        },
      });
      instance.onAction({ type: at.INIT, data: {} });

      assert.calledOnce(sectionsManagerStub.onceInitialized);
    });
    it("should bind parseOptions to SectionsManager.onceInitialized when discovery stream is false", () => {
      instance.store.getState = () => ({
        Prefs: {
          values: {
            "discoverystream.config": JSON.stringify({ enabled: false }),
            "feeds.section.topstories": true,
          },
        },
      });
      instance.onAction({ type: at.INIT, data: {} });
      assert.calledOnce(sectionsManagerStub.onceInitialized);
    });
    it("Should initialize properties once while lazy loading if not initialized earlier", () => {
      instance.discoveryStreamEnabled = false;
      instance.propertiesInitialized = false;
      sinon.stub(instance, "initializeProperties");
      instance.lazyLoadTopStories();
      assert.calledOnce(instance.initializeProperties);
    });
    it("should not re-initialize properties", () => {
      // For discovery stream experience disabled TopStoriesFeed properties
      // are initialized in constructor and should not be called again while lazy loading topstories
      sinon.stub(instance, "initializeProperties");
      instance.discoveryStreamEnabled = false;
      instance.propertiesInitialized = true;
      instance.lazyLoadTopStories();
      assert.notCalled(instance.initializeProperties);
    });
    it("should have early exit onInit when discovery is true", async () => {
      sinon.stub(instance, "doContentUpdate");
      await instance.onInit();
      assert.notCalled(instance.doContentUpdate);
      assert.isUndefined(instance.storiesLoaded);
    });
    it("should complete onInit when discovery is false", async () => {
      instance.discoveryStreamEnabled = false;
      sinon.stub(instance, "doContentUpdate");
      await instance.onInit();
      assert.calledOnce(instance.doContentUpdate);
      assert.isTrue(instance.storiesLoaded);
    });
    it("should handle limited actions when discoverystream is enabled", async () => {
      sinon.spy(instance, "handleDisabled");
      sinon.stub(instance, "getPocketState");
      instance.store.getState = () => ({
        Prefs: {
          values: {
            "discoverystream.config": JSON.stringify({ enabled: true }),
            "discoverystream.enabled": true,
            "feeds.section.topstories": true,
          },
        },
      });

      instance.onAction({ type: at.INIT, data: {} });

      assert.calledOnce(instance.handleDisabled);
      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      assert.notCalled(instance.getPocketState);
    });
    it("should handle NEW_TAB_REHYDRATED when discoverystream is disabled", async () => {
      instance.discoveryStreamEnabled = false;
      sinon.spy(instance, "handleDisabled");
      sinon.stub(instance, "getPocketState");
      instance.store.getState = () => ({
        Prefs: {
          values: {
            "discoverystream.config": JSON.stringify({ enabled: false }),
            "feeds.section.topstories": true,
          },
        },
      });
      instance.onAction({ type: at.INIT, data: {} });
      assert.notCalled(instance.handleDisabled);

      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      assert.calledOnce(instance.getPocketState);
    });
    it("should handle UNINIT when discoverystream is enabled", async () => {
      sinon.stub(instance, "uninit");
      instance.onAction({ type: at.UNINIT });
      assert.calledOnce(instance.uninit);
    });
    it("should fire init on PREF_CHANGED", () => {
      sinon.stub(instance, "onInit");
      instance.onAction({
        type: at.PREF_CHANGED,
        data: { name: "discoverystream.config", value: {} },
      });
      assert.calledOnce(instance.onInit);
    });
    it("should fire init on DISCOVERY_STREAM_PREF_ENABLED", () => {
      sinon.stub(instance, "onInit");
      instance.onAction({
        type: at.PREF_CHANGED,
        data: { name: "discoverystream.enabled", value: true },
      });
      assert.calledOnce(instance.onInit);
    });
    it("should not fire init on PREF_CHANGED if stories are loaded", () => {
      sinon.stub(instance, "onInit");
      sinon.spy(instance, "lazyLoadTopStories");
      instance.storiesLoaded = true;
      instance.onAction({
        type: at.PREF_CHANGED,
        data: { name: "discoverystream.config", value: {} },
      });
      assert.calledOnce(instance.lazyLoadTopStories);
      assert.notCalled(instance.onInit);
    });
    it("should fire init on PREF_CHANGED when discoverystream is disabled", () => {
      instance.discoveryStreamEnabled = false;
      sinon.stub(instance, "onInit");
      instance.onAction({
        type: at.PREF_CHANGED,
        data: { name: "discoverystream.config", value: {} },
      });
      assert.calledOnce(instance.onInit);
    });
    it("should not fire init on PREF_CHANGED when discoverystream is disabled and stories are loaded", () => {
      instance.discoveryStreamEnabled = false;
      sinon.stub(instance, "onInit");
      sinon.spy(instance, "lazyLoadTopStories");
      instance.storiesLoaded = true;
      instance.onAction({
        type: at.PREF_CHANGED,
        data: { name: "discoverystream.config", value: {} },
      });
      assert.calledOnce(instance.lazyLoadTopStories);
      assert.notCalled(instance.onInit);
    });
    it("should not init props if ds pref is true", () => {
      sinon.stub(instance, "initializeProperties");
      instance.propertiesInitialized = false;
      instance.store.getState = () => ({
        Prefs: {
          values: {
            "discoverystream.config": JSON.stringify({ enabled: false }),
            "discoverystream.enabled": true,
            "feeds.section.topstories": true,
          },
        },
      });
      instance.lazyLoadTopStories({
        dsPref: JSON.stringify({ enabled: true }),
      });
      assert.notCalled(instance.initializeProperties);
    });
    it("should fire init if user pref is true", () => {
      sinon.stub(instance, "onInit");
      instance.store.getState = () => ({
        Prefs: {
          values: {
            "discoverystream.config": JSON.stringify({ enabled: false }),
            "discoverystream.enabled": false,
            "feeds.section.topstories": false,
          },
        },
      });
      instance.lazyLoadTopStories({ userPref: true });
      assert.calledOnce(instance.onInit);
    });
    it("should fire uninit if topstories update to false", () => {
      sinon.stub(instance, "uninit");
      instance.discoveryStreamEnabled = false;
      instance.onAction({
        type: at.PREF_CHANGED,
        data: {
          value: false,
          name: "feeds.section.topstories",
        },
      });
      assert.calledOnce(instance.uninit);
      instance.discoveryStreamEnabled = true;
      instance.onAction({
        type: at.PREF_CHANGED,
        data: {
          value: false,
          name: "feeds.section.topstories",
        },
      });
      assert.calledTwice(instance.uninit);
    });
    it("should fire lazyLoadTopstories if topstories update to true", () => {
      sinon.stub(instance, "lazyLoadTopStories");
      instance.discoveryStreamEnabled = false;
      instance.onAction({
        type: at.PREF_CHANGED,
        data: {
          value: true,
          name: "feeds.section.topstories",
        },
      });
      assert.calledOnce(instance.lazyLoadTopStories);
      instance.discoveryStreamEnabled = true;
      instance.onAction({
        type: at.PREF_CHANGED,
        data: {
          value: true,
          name: "feeds.section.topstories",
        },
      });
      assert.calledTwice(instance.lazyLoadTopStories);
    });
  });

  describe("#init", () => {
    it("should create a TopStoriesFeed", () => {
      assert.instanceOf(instance, TopStoriesFeed);
    });
    it("should bind parseOptions to SectionsManager.onceInitialized", () => {
      instance.onAction({ type: at.INIT, data: {} });
      assert.calledOnce(sectionsManagerStub.onceInitialized);
    });
    it("should initialize endpoints based on options", async () => {
      await instance.onInit();
      assert.equal(
        "https://somedomain.org/stories?key=test-api-key",
        instance.stories_endpoint
      );
      assert.equal(
        "https://somedomain.org/referrer",
        instance.stories_referrer
      );
      assert.equal(
        "https://somedomain.org/topics?key=test-api-key",
        instance.topics_endpoint
      );
    });
    it("should enable its section", () => {
      instance.onAction({ type: at.INIT, data: {} });
      assert.calledOnce(sectionsManagerStub.enableSection);
      assert.calledWith(sectionsManagerStub.enableSection, SECTION_ID);
    });
    it("init should fire onInit", () => {
      instance.onInit = sinon.spy();
      instance.onAction({ type: at.INIT, data: {} });
      assert.calledOnce(instance.onInit);
    });
    it("should fetch stories on init", async () => {
      instance.fetchStories = sinon.spy();
      await instance.onInit();
      assert.calledOnce(instance.fetchStories);
    });
    it("should fetch topics on init", async () => {
      instance.fetchTopics = sinon.spy();
      await instance.onInit();
      assert.calledOnce(instance.fetchTopics);
    });
    it("should not fetch if endpoint not configured", () => {
      let fetchStub = globals.sandbox.stub();
      globals.set("fetch", fetchStub);
      sectionsManagerStub.sections.set("topstories", { options: {} });
      instance.init();
      assert.notCalled(fetchStub);
    });
    it("should report error for invalid configuration", () => {
      globals.sandbox.spy(global.console, "error");
      sectionsManagerStub.sections.set("topstories", {
        options: {
          api_key_pref: "invalid",
          stories_endpoint: "https://invalid.com/?apiKey=$apiKey",
        },
      });
      instance.init();

      assert.calledWith(
        console.error,
        "Problem initializing top stories feed: An API key was specified but none configured: https://invalid.com/?apiKey=$apiKey"
      );
    });
    it("should report error for missing api key", () => {
      globals.sandbox.spy(global.console, "error");
      sectionsManagerStub.sections.set("topstories", {
        options: {
          stories_endpoint: "https://somedomain.org/stories?key=$apiKey",
          topics_endpoint: "https://somedomain.org/topics?key=$apiKey",
        },
      });
      instance.init();

      assert.called(console.error);
    });
    it("should load data from cache on init", async () => {
      instance.loadCachedData = sinon.spy();
      await instance.onInit();
      assert.calledOnce(instance.loadCachedData);
    });
  });
  describe("#uninit", () => {
    it("should disable its section", () => {
      instance.onAction({ type: at.UNINIT });
      assert.calledOnce(sectionsManagerStub.disableSection);
      assert.calledWith(sectionsManagerStub.disableSection, SECTION_ID);
    });
    it("should unload stories on uninit", async () => {
      sinon.stub(instance.cache, "set").returns(Promise.resolve());
      await instance.clearCache();
      assert.calledWith(instance.cache.set.firstCall, "stories", {});
      assert.calledWith(instance.cache.set.secondCall, "topics", {});
      assert.calledWith(instance.cache.set.thirdCall, "spocs", {});
    });
  });
  describe("#cache", () => {
    it("should clear all cache items when calling clearCache", () => {
      sinon.stub(instance.cache, "set").returns(Promise.resolve());
      instance.storiesLoaded = true;
      instance.uninit();
      assert.equal(instance.storiesLoaded, false);
    });
    it("should set spocs cache on fetch", async () => {
      const response = {
        recommendations: [{ id: "1" }, { id: "2" }],
        settings: {},
        spocs: [{ id: "spoc1" }],
      };

      instance.show_spocs = true;
      instance.stories_endpoint = "stories-endpoint";

      let fetchStub = globals.sandbox.stub();
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } });
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      sinon.spy(instance.cache, "set");

      await instance.fetchStories();

      assert.calledOnce(instance.cache.set);
      const { args } = instance.cache.set.firstCall;
      assert.equal(args[0], "stories");
      assert.equal(args[1].spocs[0].id, "spoc1");
    });
    it("should get spocs on cache load", async () => {
      instance.cache.get = () => ({
        stories: {
          recommendations: [{ id: "1" }, { id: "2" }],
          spocs: [{ id: "spoc1" }],
        },
      });
      instance.storiesLastUpdated = 0;
      globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } });

      await instance.loadCachedData();
      assert.equal(instance.spocs[0].guid, "spoc1");
    });
  });
  describe("#fetch", () => {
    it("should fetch stories, send event and cache results", async () => {
      let fetchStub = globals.sandbox.stub();
      sectionsManagerStub.sections.set("topstories", {
        options: {
          stories_endpoint: "stories-endpoint",
          stories_referrer: "referrer",
        },
      });
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });

      const response = {
        recommendations: [
          {
            id: "1",
            title: "title",
            excerpt: "description",
            image_src: "image-url",
            url: "rec-url",
            published_timestamp: "123",
            context: "trending",
            icon: "icon",
          },
        ],
      };
      const stories = [
        {
          guid: "1",
          type: "now",
          title: "title",
          context: "trending",
          icon: "icon",
          description: "description",
          image: "image-url",
          referrer: "referrer",
          url: "rec-url",
          hostname: "rec-url",
          score: 1,
          spoc_meta: {},
        },
      ];

      instance.cache.set = sinon.spy();
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      await instance.onInit();

      assert.calledOnce(fetchStub);
      assert.calledOnce(shortURLStub);
      assert.calledWithExactly(fetchStub, instance.stories_endpoint, {
        credentials: "omit",
      });
      assert.calledOnce(sectionsManagerStub.updateSection);
      assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {
        rows: stories,
      });
      assert.calledOnce(instance.cache.set);
      assert.calledWith(
        instance.cache.set,
        "stories",
        Object.assign({}, response, { _timestamp: 0 })
      );
    });
    it("should use domain as hostname, if present", async () => {
      let fetchStub = globals.sandbox.stub();
      sectionsManagerStub.sections.set("topstories", {
        options: {
          stories_endpoint: "stories-endpoint",
          stories_referrer: "referrer",
        },
      });
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });

      const response = {
        recommendations: [
          {
            id: "1",
            title: "title",
            excerpt: "description",
            image_src: "image-url",
            url: "rec-url",
            domain: "domain",
            published_timestamp: "123",
            context: "trending",
            icon: "icon",
          },
        ],
      };
      const stories = [
        {
          guid: "1",
          type: "now",
          title: "title",
          context: "trending",
          icon: "icon",
          description: "description",
          image: "image-url",
          referrer: "referrer",
          url: "rec-url",
          hostname: "domain",
          score: 1,
          spoc_meta: {},
        },
      ];

      instance.cache.set = sinon.spy();
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      await instance.onInit();

      assert.calledOnce(fetchStub);
      assert.notCalled(shortURLStub);
      assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {
        rows: stories,
      });
    });
    it("should call SectionsManager.updateSection", () => {
      instance.dispatchUpdateEvent(123, {});
      assert.calledOnce(sectionsManagerStub.updateSection);
    });
    it("should report error for unexpected stories response", async () => {
      let fetchStub = globals.sandbox.stub();
      sectionsManagerStub.sections.set("topstories", {
        options: { stories_endpoint: "stories-endpoint" },
      });
      globals.set("fetch", fetchStub);
      globals.sandbox.spy(global.console, "error");

      fetchStub.resolves({ ok: false, status: 400 });
      await instance.onInit();

      assert.calledOnce(fetchStub);
      assert.calledWithExactly(fetchStub, instance.stories_endpoint, {
        credentials: "omit",
      });
      assert.equal(instance.storiesLastUpdated, 0);
      assert.called(console.error);
    });
    it("should exclude blocked (dismissed) URLs", async () => {
      let fetchStub = globals.sandbox.stub();
      sectionsManagerStub.sections.set("topstories", {
        options: { stories_endpoint: "stories-endpoint" },
      });
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: site => site.url === "blocked" },
      });

      const response = {
        recommendations: [{ url: "blocked" }, { url: "not_blocked" }],
      };
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      await instance.onInit();

      // Issue!
      // Should actually be fixed when cache is fixed.
      assert.calledOnce(sectionsManagerStub.updateSection);
      assert.equal(
        sectionsManagerStub.updateSection.firstCall.args[1].rows.length,
        1
      );
      assert.equal(
        sectionsManagerStub.updateSection.firstCall.args[1].rows[0].url,
        "not_blocked"
      );
    });
    it("should mark stories as new", async () => {
      let fetchStub = globals.sandbox.stub();
      sectionsManagerStub.sections.set("topstories", {
        options: { stories_endpoint: "stories-endpoint" },
      });
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });
      clock.restore();
      const response = {
        recommendations: [
          { published_timestamp: Date.now() / 1000 },
          { published_timestamp: "0" },
          {
            published_timestamp: (Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000,
          },
        ],
      };

      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });

      await instance.onInit();
      assert.calledOnce(sectionsManagerStub.updateSection);
      assert.equal(
        sectionsManagerStub.updateSection.firstCall.args[1].rows.length,
        3
      );
      assert.equal(
        sectionsManagerStub.updateSection.firstCall.args[1].rows[0].type,
        "now"
      );
      assert.equal(
        sectionsManagerStub.updateSection.firstCall.args[1].rows[1].type,
        "trending"
      );
      assert.equal(
        sectionsManagerStub.updateSection.firstCall.args[1].rows[2].type,
        "trending"
      );
    });
    it("should fetch topics, send event and cache results", async () => {
      let fetchStub = globals.sandbox.stub();
      sectionsManagerStub.sections.set("topstories", {
        options: { topics_endpoint: "topics-endpoint" },
      });
      globals.set("fetch", fetchStub);

      const response = {
        topics: [
          { name: "topic1", url: "url-topic1" },
          { name: "topic2", url: "url-topic2" },
        ],
      };
      const topics = [
        {
          name: "topic1",
          url: "url-topic1",
        },
        {
          name: "topic2",
          url: "url-topic2",
        },
      ];

      instance.cache.set = sinon.spy();
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      await instance.onInit();

      assert.calledOnce(fetchStub);
      assert.calledWithExactly(fetchStub, instance.topics_endpoint, {
        credentials: "omit",
      });
      assert.calledOnce(sectionsManagerStub.updateSection);
      assert.calledWithMatch(sectionsManagerStub.updateSection, SECTION_ID, {
        topics,
      });
      assert.calledOnce(instance.cache.set);
      assert.calledWith(
        instance.cache.set,
        "topics",
        Object.assign({}, response, { _timestamp: 0 })
      );
    });
    it("should report error for unexpected topics response", async () => {
      let fetchStub = globals.sandbox.stub();
      globals.set("fetch", fetchStub);
      globals.sandbox.spy(global.console, "error");

      instance.topics_endpoint = "topics-endpoint";
      fetchStub.resolves({ ok: false, status: 400 });
      await instance.fetchTopics();

      assert.calledOnce(fetchStub);
      assert.calledWithExactly(fetchStub, instance.topics_endpoint, {
        credentials: "omit",
      });
      assert.notCalled(instance.store.dispatch);
      assert.called(console.error);
    });
  });
  describe("#personalization", () => {
    it("should sort stories", async () => {
      const response = {
        recommendations: [{ id: "1" }, { id: "2" }],
        settings: {},
      };

      instance.compareScore = sinon.spy();
      instance.stories_endpoint = "stories-endpoint";

      let fetchStub = globals.sandbox.stub();
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });

      await instance.fetchStories();
      assert.calledOnce(instance.compareScore);
    });
    it("should sort items based on relevance score", () => {
      let items = [{ score: 0.1 }, { score: 0.2 }];
      items = items.sort(instance.compareScore);
      assert.deepEqual(items, [{ score: 0.2 }, { score: 0.1 }]);
    });
    it("should rotate items", () => {
      let items = [
        { guid: "g1" },
        { guid: "g2" },
        { guid: "g3" },
        { guid: "g4" },
        { guid: "g5" },
        { guid: "g6" },
      ];

      // No impressions should leave items unchanged
      let rotated = instance.rotate(items);
      assert.deepEqual(items, rotated);

      // Recent impression should leave items unchanged
      instance._prefs.get = pref =>
        pref === REC_IMPRESSION_TRACKING_PREF &&
        JSON.stringify({ g1: 1, g2: 1, g3: 1 });
      rotated = instance.rotate(items);
      assert.deepEqual(items, rotated);

      // Impression older than expiration time should rotate items
      clock.tick(DEFAULT_RECS_EXPIRE_TIME + 1);
      rotated = instance.rotate(items);
      assert.deepEqual(
        [
          { guid: "g4" },
          { guid: "g5" },
          { guid: "g6" },
          { guid: "g1" },
          { guid: "g2" },
          { guid: "g3" },
        ],
        rotated
      );

      instance._prefs.get = pref =>
        pref === REC_IMPRESSION_TRACKING_PREF &&
        JSON.stringify({
          g1: 1,
          g2: 1,
          g3: 1,
          g4: DEFAULT_RECS_EXPIRE_TIME + 1,
        });
      clock.tick(DEFAULT_RECS_EXPIRE_TIME);
      rotated = instance.rotate(items);
      assert.deepEqual(
        [
          { guid: "g5" },
          { guid: "g6" },
          { guid: "g1" },
          { guid: "g2" },
          { guid: "g3" },
          { guid: "g4" },
        ],
        rotated
      );
    });
    it("should record top story impressions", async () => {
      instance._prefs = { get: pref => undefined, set: sinon.spy() };

      clock.tick(1);
      let expectedPrefValue = JSON.stringify({ 1: 1, 2: 1, 3: 1 });
      instance.onAction({
        type: at.TELEMETRY_IMPRESSION_STATS,
        data: {
          source: "TOP_STORIES",
          tiles: [{ id: 1 }, { id: 2 }, { id: 3 }],
        },
      });
      assert.calledWith(
        instance._prefs.set.firstCall,
        REC_IMPRESSION_TRACKING_PREF,
        expectedPrefValue
      );

      // Only need to record first impression, so impression pref shouldn't change
      instance._prefs.get = pref => expectedPrefValue;
      clock.tick(1);
      instance.onAction({
        type: at.TELEMETRY_IMPRESSION_STATS,
        data: {
          source: "TOP_STORIES",
          tiles: [{ id: 1 }, { id: 2 }, { id: 3 }],
        },
      });
      assert.calledOnce(instance._prefs.set);

      // New first impressions should be added
      clock.tick(1);
      let expectedPrefValueTwo = JSON.stringify({
        1: 1,
        2: 1,
        3: 1,
        4: 3,
        5: 3,
        6: 3,
      });
      instance.onAction({
        type: at.TELEMETRY_IMPRESSION_STATS,
        data: {
          source: "TOP_STORIES",
          tiles: [{ id: 4 }, { id: 5 }, { id: 6 }],
        },
      });
      assert.calledWith(
        instance._prefs.set.secondCall,
        REC_IMPRESSION_TRACKING_PREF,
        expectedPrefValueTwo
      );
    });
    it("should not record top story impressions for non-view impressions", async () => {
      instance._prefs = { get: pref => undefined, set: sinon.spy() };

      instance.onAction({
        type: at.TELEMETRY_IMPRESSION_STATS,
        data: { source: "TOP_STORIES", click: 0, tiles: [{ id: 1 }] },
      });
      assert.notCalled(instance._prefs.set);

      instance.onAction({
        type: at.TELEMETRY_IMPRESSION_STATS,
        data: { source: "TOP_STORIES", block: 0, tiles: [{ id: 1 }] },
      });
      assert.notCalled(instance._prefs.set);

      instance.onAction({
        type: at.TELEMETRY_IMPRESSION_STATS,
        data: { source: "TOP_STORIES", pocket: 0, tiles: [{ id: 1 }] },
      });
      assert.notCalled(instance._prefs.set);
    });
    it("should clean up top story impressions", async () => {
      instance._prefs = {
        get: pref => JSON.stringify({ 1: 1, 2: 1, 3: 1 }),
        set: sinon.spy(),
      };

      let fetchStub = globals.sandbox.stub();
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });

      instance.stories_endpoint = "stories-endpoint";
      const response = { recommendations: [{ id: 3 }, { id: 4 }, { id: 5 }] };
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      await instance.fetchStories();

      // Should remove impressions for rec 1 and 2 as no longer in the feed
      assert.calledWith(
        instance._prefs.set.firstCall,
        REC_IMPRESSION_TRACKING_PREF,
        JSON.stringify({ 3: 1 })
      );
    });
    it("should not change provider with badly formed JSON", async () => {
      sinon.stub(instance, "uninit");
      sinon.stub(instance, "init");
      sinon.stub(instance, "clearCache").returns(Promise.resolve());
      await instance.onAction({
        type: at.PREF_CHANGED,
        data: {
          name: "feeds.section.topstories.options",
          value: "{version: 2}",
        },
      });
      assert.notCalled(instance.uninit);
      assert.notCalled(instance.init);
      assert.notCalled(instance.clearCache);
    });
  });
  describe("#spocs", async () => {
    it("should not display expired or untimestamped spocs", async () => {
      clock.tick(441792000000); // 01/01/1984

      instance.spocsPerNewTabs = 1;
      instance.show_spocs = true;
      instance.isBelowFrequencyCap = () => true;

      // NOTE: `expiration_timestamp` is seconds since UNIX epoch
      instance.spocs = [
        // No timestamp stays visible
        {
          id: "spoc1",
        },
        // Expired spoc gets filtered out
        {
          id: "spoc2",
          expiration_timestamp: 1,
        },
        // Far future expiration spoc stays visible
        {
          id: "spoc3",
          expiration_timestamp: 32503708800, // 01/01/3000
        },
      ];

      sinon.spy(instance, "filterSpocs");

      instance.filterSpocs();

      assert.equal(instance.filterSpocs.firstCall.returnValue.length, 2);
      assert.equal(instance.filterSpocs.firstCall.returnValue[0].id, "spoc1");
      assert.equal(instance.filterSpocs.firstCall.returnValue[1].id, "spoc3");
    });
    it("should insert spoc with provided probability", async () => {
      let fetchStub = globals.sandbox.stub();
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });

      const response = {
        settings: { spocsPerNewTabs: 0.5 },
        recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
        // Include spocs with a expiration in the very distant future
        spocs: [
          { id: "spoc1", expiration_timestamp: 9999999999999 },
          { id: "spoc2", expiration_timestamp: 9999999999999 },
        ],
      };

      instance.show_spocs = true;
      instance.stories_endpoint = "stories-endpoint";
      instance.storiesLoaded = true;
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      await instance.fetchStories();

      instance.store.getState = () => ({
        Sections: [{ id: "topstories", rows: response.recommendations }],
        Prefs: { values: { showSponsored: true } },
      });

      globals.set("Math", {
        random: () => 0.4,
        min: Math.min,
      });
      instance.dispatchSpocDone = () => {};
      instance.getPocketState = () => {};

      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      assert.calledOnce(instance.store.dispatch);
      let [action] = instance.store.dispatch.firstCall.args;

      assert.equal(at.SECTION_UPDATE, action.type);
      assert.equal(true, action.meta.skipMain);
      assert.equal(action.data.rows[0].guid, "rec1");
      assert.equal(action.data.rows[1].guid, "rec2");
      assert.equal(action.data.rows[2].guid, "spoc1");
      // Make sure spoc is marked as pinned so it doesn't get removed when preloaded tabs refresh
      assert.equal(action.data.rows[2].pinned, true);

      // Second new tab shouldn't trigger a section update event (spocsPerNewTab === 0.5)
      globals.set("Math", {
        random: () => 0.6,
        min: Math.min,
      });
      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      assert.calledOnce(instance.store.dispatch);

      globals.set("Math", {
        random: () => 0.3,
        min: Math.min,
      });
      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      assert.calledTwice(instance.store.dispatch);
      [action] = instance.store.dispatch.secondCall.args;
      assert.equal(at.SECTION_UPDATE, action.type);
      assert.equal(true, action.meta.skipMain);
      assert.equal(action.data.rows[0].guid, "rec1");
      assert.equal(action.data.rows[1].guid, "rec2");
      assert.equal(action.data.rows[2].guid, "spoc1");
      // Make sure spoc is marked as pinned so it doesn't get removed when preloaded tabs refresh
      assert.equal(action.data.rows[2].pinned, true);
    });
    it("should delay inserting spoc if stories haven't been fetched", async () => {
      let fetchStub = globals.sandbox.stub();
      instance.dispatchSpocDone = () => {};
      sectionsManagerStub.sections.set("topstories", {
        options: {
          show_spocs: true,
          stories_endpoint: "stories-endpoint",
        },
      });
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });
      globals.set("Math", {
        random: () => 0.4,
        min: Math.min,
        floor: Math.floor,
      });
      instance.getPocketState = () => {};
      instance.dispatchPocketCta = () => {};

      const response = {
        settings: { spocsPerNewTabs: 0.5 },
        recommendations: [{ id: "rec1" }, { id: "rec2" }, { id: "rec3" }],
        // Include one spoc with a expiration in the very distant future
        spocs: [
          { id: "spoc1", expiration_timestamp: 9999999999999 },
          { id: "spoc2" },
        ],
      };

      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      assert.notCalled(instance.store.dispatch);
      assert.equal(instance.contentUpdateQueue.length, 1);

      instance.spocsPerNewTabs = 0.5;
      instance.store.getState = () => ({
        Sections: [{ id: "topstories", rows: response.recommendations }],
        Prefs: { values: { showSponsored: true } },
      });
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });

      await instance.onInit();
      assert.equal(instance.contentUpdateQueue.length, 0);
      assert.calledOnce(instance.store.dispatch);
      let [action] = instance.store.dispatch.firstCall.args;
      assert.equal(action.type, at.SECTION_UPDATE);
    });
    it("should not insert spoc if preffed off", async () => {
      let fetchStub = globals.sandbox.stub();
      instance.dispatchSpocDone = () => {};
      sectionsManagerStub.sections.set("topstories", {
        options: {
          show_spocs: false,
          stories_endpoint: "stories-endpoint",
        },
      });
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });
      instance.getPocketState = () => {};
      instance.dispatchPocketCta = () => {};

      const response = {
        settings: { spocsPerNewTabs: 0.5 },
        spocs: [{ id: "spoc1" }, { id: "spoc2" }],
      };
      sinon.spy(instance, "maybeAddSpoc");
      sinon.spy(instance, "shouldShowSpocs");

      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      await instance.onInit();

      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      assert.calledOnce(instance.maybeAddSpoc);
      assert.calledOnce(instance.shouldShowSpocs);
      assert.notCalled(instance.store.dispatch);
    });
    it("should call dispatchSpocDone when calling maybeAddSpoc", async () => {
      instance.dispatchSpocDone = sinon.spy();
      instance.storiesLoaded = true;
      await instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      assert.calledOnce(instance.dispatchSpocDone);
      assert.calledWith(instance.dispatchSpocDone, {});
    });
    it("should fire POCKET_WAITING_FOR_SPOC action with false", () => {
      instance.dispatchSpocDone({});
      assert.calledOnce(instance.store.dispatch);
      const [action] = instance.store.dispatch.firstCall.args;
      assert.equal(action.type, "POCKET_WAITING_FOR_SPOC");
      assert.equal(action.data, false);
    });
    it("should not insert spoc if user opted out", async () => {
      let fetchStub = globals.sandbox.stub();
      instance.dispatchSpocDone = () => {};
      sectionsManagerStub.sections.set("topstories", {
        options: {
          show_spocs: true,
          stories_endpoint: "stories-endpoint",
        },
      });
      instance.getPocketState = () => {};
      instance.dispatchPocketCta = () => {};
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });

      const response = {
        settings: { spocsPerNewTabs: 0.5 },
        spocs: [{ id: "spoc1" }, { id: "spoc2" }],
      };

      instance.store.getState = () => ({
        Sections: [{ id: "topstories", rows: response.recommendations }],
        Prefs: { values: { showSponsored: false } },
      });
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      await instance.onInit();

      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      assert.notCalled(instance.store.dispatch);
    });
    it("should not fail if there is no spoc", async () => {
      let fetchStub = globals.sandbox.stub();
      instance.dispatchSpocDone = () => {};
      sectionsManagerStub.sections.set("topstories", {
        options: {
          show_spocs: true,
          stories_endpoint: "stories-endpoint",
        },
      });
      instance.getPocketState = () => {};
      instance.dispatchPocketCta = () => {};
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });
      globals.set("Math", {
        random: () => 0.4,
        min: Math.min,
      });

      const response = {
        settings: { spocsPerNewTabs: 0.5 },
        recommendations: [{ id: "rec1" }, { id: "rec2" }, { id: "rec3" }],
      };

      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      await instance.onInit();

      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      assert.notCalled(instance.store.dispatch);
    });
    it("should record spoc/campaign impressions for frequency capping", async () => {
      let fetchStub = globals.sandbox.stub();
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });
      globals.set("Math", {
        random: () => 0.4,
        min: Math.min,
        floor: Math.floor,
      });

      const response = {
        settings: { spocsPerNewTabs: 0.5 },
        spocs: [
          { id: 1, campaign_id: 5 },
          { id: 4, campaign_id: 6 },
        ],
      };

      instance._prefs = { get: pref => undefined, set: sinon.spy() };
      instance.show_spocs = true;
      instance.stories_endpoint = "stories-endpoint";
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      await instance.fetchStories();

      let expectedPrefValue = JSON.stringify({ 5: [0] });
      let expectedPrefValueCallTwo = JSON.stringify({ 2: 0, 3: 0 });
      instance.onAction({
        type: at.TELEMETRY_IMPRESSION_STATS,
        data: {
          source: "TOP_STORIES",
          tiles: [{ id: 3 }, { id: 2 }, { id: 1 }],
        },
      });
      assert.calledWith(
        instance._prefs.set.firstCall,
        SPOC_IMPRESSION_TRACKING_PREF,
        expectedPrefValue
      );
      assert.calledWith(
        instance._prefs.set.secondCall,
        REC_IMPRESSION_TRACKING_PREF,
        expectedPrefValueCallTwo
      );

      clock.tick(1);
      instance._prefs.get = pref => expectedPrefValue;
      let expectedPrefValueCallThree = JSON.stringify({ 5: [0, 1] });
      let expectedPrefValueCallFour = JSON.stringify({ 2: 1, 3: 1, 5: [0] });
      instance.onAction({
        type: at.TELEMETRY_IMPRESSION_STATS,
        data: {
          source: "TOP_STORIES",
          tiles: [{ id: 3 }, { id: 2 }, { id: 1 }],
        },
      });
      assert.calledWith(
        instance._prefs.set.thirdCall,
        SPOC_IMPRESSION_TRACKING_PREF,
        expectedPrefValueCallThree
      );
      assert.calledWith(
        instance._prefs.set.getCall(3),
        REC_IMPRESSION_TRACKING_PREF,
        expectedPrefValueCallFour
      );

      clock.tick(1);
      instance._prefs.get = pref => expectedPrefValueCallThree;
      instance.onAction({
        type: at.TELEMETRY_IMPRESSION_STATS,
        data: {
          source: "TOP_STORIES",
          tiles: [{ id: 3 }, { id: 2 }, { id: 4 }],
        },
      });
      assert.calledWith(
        instance._prefs.set.getCall(4),
        SPOC_IMPRESSION_TRACKING_PREF,
        JSON.stringify({ 5: [0, 1], 6: [2] })
      );
      assert.calledWith(
        instance._prefs.set.getCall(5),
        REC_IMPRESSION_TRACKING_PREF,
        JSON.stringify({ 2: 2, 3: 2, 5: [0, 1] })
      );
    });
    it("should not record spoc/campaign impressions for non-view impressions", async () => {
      let fetchStub = globals.sandbox.stub();
      sectionsManagerStub.sections.set("topstories", {
        options: {
          show_spocs: true,
          stories_endpoint: "stories-endpoint",
        },
      });
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });

      const response = {
        settings: { spocsPerNewTabs: 0.5 },
        spocs: [
          { id: 1, campaign_id: 5 },
          { id: 4, campaign_id: 6 },
        ],
      };

      instance._prefs = { get: pref => undefined, set: sinon.spy() };
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      await instance.onInit();

      instance.onAction({
        type: at.TELEMETRY_IMPRESSION_STATS,
        data: { source: "TOP_STORIES", click: 0, tiles: [{ id: 1 }] },
      });
      assert.notCalled(instance._prefs.set);

      instance.onAction({
        type: at.TELEMETRY_IMPRESSION_STATS,
        data: { source: "TOP_STORIES", block: 0, tiles: [{ id: 1 }] },
      });
      assert.notCalled(instance._prefs.set);

      instance.onAction({
        type: at.TELEMETRY_IMPRESSION_STATS,
        data: { source: "TOP_STORIES", pocket: 0, tiles: [{ id: 1 }] },
      });
      assert.notCalled(instance._prefs.set);
    });
    it("should clean up spoc/campaign impressions", async () => {
      let fetchStub = globals.sandbox.stub();
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });

      instance._prefs = { get: pref => undefined, set: sinon.spy() };
      instance.show_spocs = true;
      instance.stories_endpoint = "stories-endpoint";

      const response = {
        settings: { spocsPerNewTabs: 0.5 },
        spocs: [
          { id: 1, campaign_id: 5 },
          { id: 4, campaign_id: 6 },
        ],
      };
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      await instance.fetchStories();

      // simulate impressions for campaign 5 and 6
      instance.onAction({
        type: at.TELEMETRY_IMPRESSION_STATS,
        data: {
          source: "TOP_STORIES",
          tiles: [{ id: 3 }, { id: 2 }, { id: 1 }],
        },
      });
      instance._prefs.get = pref =>
        pref === SPOC_IMPRESSION_TRACKING_PREF && JSON.stringify({ 5: [0] });
      instance.onAction({
        type: at.TELEMETRY_IMPRESSION_STATS,
        data: {
          source: "TOP_STORIES",
          tiles: [{ id: 3 }, { id: 2 }, { id: 4 }],
        },
      });

      let expectedPrefValue = JSON.stringify({ 5: [0], 6: [0] });
      assert.calledWith(
        instance._prefs.set.thirdCall,
        SPOC_IMPRESSION_TRACKING_PREF,
        expectedPrefValue
      );
      instance._prefs.get = pref =>
        pref === SPOC_IMPRESSION_TRACKING_PREF && expectedPrefValue;

      // remove campaign 5 from response
      const updatedResponse = {
        settings: { spocsPerNewTabs: 1 },
        spocs: [{ id: 4, campaign_id: 6 }],
      };
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(updatedResponse),
      });
      await instance.fetchStories();

      // should remove campaign 5 from pref as no longer active
      assert.calledWith(
        instance._prefs.set.getCall(4),
        SPOC_IMPRESSION_TRACKING_PREF,
        JSON.stringify({ 6: [0] })
      );
    });
    it("should maintain frequency caps when inserting spocs", async () => {
      let fetchStub = globals.sandbox.stub();
      instance.dispatchSpocDone = () => {};
      sectionsManagerStub.sections.set("topstories", {
        options: {
          show_spocs: true,
          stories_endpoint: "stories-endpoint",
        },
      });
      instance.getPocketState = () => {};
      instance.dispatchPocketCta = () => {};
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });

      const response = {
        settings: { spocsPerNewTabs: 1 },
        recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
        spocs: [
          // Set spoc `expiration_timestamp`s in the very distant future to ensure they show up
          {
            id: "spoc1",
            campaign_id: 1,
            caps: { lifetime: 3, campaign: { count: 2, period: 3600 } },
            expiration_timestamp: 999999999999,
          },
          {
            id: "spoc2",
            campaign_id: 2,
            caps: { lifetime: 1 },
            expiration_timestamp: 999999999999,
          },
        ],
      };

      instance.store.getState = () => ({
        Sections: [{ id: "topstories", rows: response.recommendations }],
        Prefs: { values: { showSponsored: true } },
      });
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      await instance.onInit();
      instance.spocsPerNewTabs = 1;

      clock.tick();
      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      let [action] = instance.store.dispatch.firstCall.args;
      assert.equal(action.data.rows[0].guid, "rec1");
      assert.equal(action.data.rows[1].guid, "rec2");
      assert.equal(action.data.rows[2].guid, "spoc1");
      instance._prefs.get = pref => JSON.stringify({ 1: [1] });

      clock.tick();
      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      [action] = instance.store.dispatch.secondCall.args;
      assert.equal(action.data.rows[0].guid, "rec1");
      assert.equal(action.data.rows[1].guid, "rec2");
      assert.equal(action.data.rows[2].guid, "spoc1");
      instance._prefs.get = pref => JSON.stringify({ 1: [1, 2] });

      // campaign 1 period frequency cap now reached (spoc 2 should be shown)
      clock.tick();
      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      [action] = instance.store.dispatch.thirdCall.args;
      assert.equal(action.data.rows[0].guid, "rec1");
      assert.equal(action.data.rows[1].guid, "rec2");
      assert.equal(action.data.rows[2].guid, "spoc2");
      instance._prefs.get = pref => JSON.stringify({ 1: [1, 2], 2: [3] });

      // new campaign 1 period starting (spoc 1 sohuld be shown again)
      clock.tick(2 * 60 * 60 * 1000);
      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      [action] = instance.store.dispatch.lastCall.args;
      assert.equal(action.data.rows[0].guid, "rec1");
      assert.equal(action.data.rows[1].guid, "rec2");
      assert.equal(action.data.rows[2].guid, "spoc1");
      instance._prefs.get = pref =>
        JSON.stringify({ 1: [1, 2, 7200003], 2: [3] });

      // campaign 1 lifetime cap now reached (no spoc should be sent)
      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      assert.callCount(instance.store.dispatch, 4);
    });
    it("should maintain client-side MAX_LIFETIME_CAP", async () => {
      let fetchStub = globals.sandbox.stub();
      instance.dispatchSpocDone = () => {};
      sectionsManagerStub.sections.set("topstories", {
        options: {
          show_spocs: true,
          stories_endpoint: "stories-endpoint",
        },
      });
      globals.set("fetch", fetchStub);
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });
      instance.getPocketState = () => {};
      instance.dispatchPocketCta = () => {};

      const response = {
        settings: { spocsPerNewTabs: 1 },
        recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
        spocs: [{ id: "spoc1", campaign_id: 1, caps: { lifetime: 501 } }],
      };

      instance.store.getState = () => ({
        Sections: [{ id: "topstories", rows: response.recommendations }],
        Prefs: { values: { showSponsored: true } },
      });
      fetchStub.resolves({
        ok: true,
        status: 200,
        json: () => Promise.resolve(response),
      });
      await instance.onInit();

      instance._prefs.get = pref =>
        JSON.stringify({ 1: [...Array(500).keys()] });
      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      assert.notCalled(instance.store.dispatch);
    });
  });
  describe("#update", () => {
    it("should fetch stories after update interval", async () => {
      await instance.onInit();
      sinon.spy(instance, "fetchStories");
      await instance.onAction({ type: at.SYSTEM_TICK });
      assert.notCalled(instance.fetchStories);

      clock.tick(STORIES_UPDATE_TIME);
      await instance.onAction({ type: at.SYSTEM_TICK });
      assert.calledOnce(instance.fetchStories);
    });
    it("should fetch topics after update interval", async () => {
      await instance.onInit();
      sinon.spy(instance, "fetchTopics");
      await instance.onAction({ type: at.SYSTEM_TICK });
      assert.notCalled(instance.fetchTopics);

      clock.tick(TOPICS_UPDATE_TIME);
      await instance.onAction({ type: at.SYSTEM_TICK });
      assert.calledOnce(instance.fetchTopics);
    });
    it("should return updated stories and topics on system tick", async () => {
      await instance.onInit();
      sinon.spy(instance, "dispatchUpdateEvent");
      const stories = [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }];
      const topics = [
        { name: "topic1", url: "url-topic1" },
        { name: "topic2", url: "url-topic2" },
      ];
      clock.tick(TOPICS_UPDATE_TIME);
      globals.sandbox.stub(instance, "fetchStories").resolves(stories);
      globals.sandbox.stub(instance, "fetchTopics").resolves(topics);

      await instance.onAction({ type: at.SYSTEM_TICK });

      assert.calledOnce(instance.dispatchUpdateEvent);
      assert.calledWith(instance.dispatchUpdateEvent, false, {
        rows: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
        topics: [
          { name: "topic1", url: "url-topic1" },
          { name: "topic2", url: "url-topic2" },
        ],
        read_more_endpoint: undefined,
      });
    });
    it("should not call init and uninit if data doesn't match on options change ", () => {
      sinon.spy(instance, "init");
      sinon.spy(instance, "uninit");
      instance.onAction({ type: at.SECTION_OPTIONS_CHANGED, data: "foo" });
      assert.notCalled(sectionsManagerStub.disableSection);
      assert.notCalled(sectionsManagerStub.enableSection);
      assert.notCalled(instance.init);
      assert.notCalled(instance.uninit);
    });
    it("should call init and uninit on options change", async () => {
      sinon.stub(instance, "clearCache").returns(Promise.resolve());
      sinon.spy(instance, "init");
      sinon.spy(instance, "uninit");
      await instance.onAction({
        type: at.SECTION_OPTIONS_CHANGED,
        data: "topstories",
      });
      assert.calledOnce(sectionsManagerStub.disableSection);
      assert.calledOnce(sectionsManagerStub.enableSection);
      assert.calledOnce(instance.clearCache);
      assert.calledOnce(instance.init);
      assert.calledOnce(instance.uninit);
    });
    it("should set LastUpdated to 0 on init", async () => {
      instance.storiesLastUpdated = 1;
      instance.topicsLastUpdated = 1;

      await instance.onInit();
      assert.equal(instance.storiesLastUpdated, 0);
      assert.equal(instance.topicsLastUpdated, 0);
    });
    it("should filter spocs when link is blocked", async () => {
      instance.spocs = [{ url: "not_blocked" }, { url: "blocked" }];
      await instance.onAction({
        type: at.PLACES_LINK_BLOCKED,
        data: { url: "blocked" },
      });

      assert.deepEqual(instance.spocs, [{ url: "not_blocked" }]);
    });
  });
  describe("#loadCachedData", () => {
    it("should update section with cached stories and topics if available", async () => {
      sectionsManagerStub.sections.set("topstories", {
        options: { stories_referrer: "referrer" },
      });
      const stories = {
        _timestamp: 123,
        recommendations: [
          {
            id: "1",
            title: "title",
            excerpt: "description",
            image_src: "image-url",
            url: "rec-url",
            published_timestamp: "123",
            context: "trending",
            icon: "icon",
            item_score: 0.98,
          },
        ],
      };
      const transformedStories = [
        {
          guid: "1",
          type: "now",
          title: "title",
          context: "trending",
          icon: "icon",
          description: "description",
          image: "image-url",
          referrer: "referrer",
          url: "rec-url",
          hostname: "rec-url",
          score: 0.98,
          spoc_meta: {},
        },
      ];
      const topics = {
        _timestamp: 123,
        topics: [
          { name: "topic1", url: "url-topic1" },
          { name: "topic2", url: "url-topic2" },
        ],
      };
      instance.cache.get = () => ({ stories, topics });
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });

      await instance.onInit();
      assert.calledOnce(sectionsManagerStub.updateSection);
      assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {
        rows: transformedStories,
        topics: topics.topics,
        read_more_endpoint: undefined,
      });
    });
    it("should NOT update section if there is no cached data", async () => {
      instance.cache.get = () => ({});
      globals.set("NewTabUtils", {
        blockedLinks: { isBlocked: globals.sandbox.spy() },
      });
      await instance.loadCachedData();
      assert.notCalled(sectionsManagerStub.updateSection);
    });
    it("should use store rows if no stories sent to doContentUpdate", async () => {
      instance.store = {
        getState() {
          return {
            Sections: [{ id: "topstories", rows: [1, 2, 3] }],
          };
        },
      };
      sinon.spy(instance, "dispatchUpdateEvent");

      instance.doContentUpdate({}, false);

      assert.calledOnce(instance.dispatchUpdateEvent);
      assert.calledWith(instance.dispatchUpdateEvent, false, {
        rows: [1, 2, 3],
      });
    });
    it("should broadcast in doContentUpdate when updating from cache", async () => {
      sectionsManagerStub.sections.set("topstories", {
        options: { stories_referrer: "referrer" },
      });
      globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } });
      const stories = { recommendations: [{}] };
      const topics = { topics: [{}] };
      sinon.spy(instance, "doContentUpdate");
      instance.cache.get = () => ({ stories, topics });
      await instance.onInit();
      assert.calledOnce(instance.doContentUpdate);
      assert.calledWith(
        instance.doContentUpdate,
        {
          stories: [
            {
              context: undefined,
              description: undefined,
              guid: undefined,
              hostname: undefined,
              icon: undefined,
              image: undefined,
              referrer: "referrer",
              score: 1,
              spoc_meta: {},
              title: undefined,
              type: "trending",
              url: undefined,
            },
          ],
          topics: [{}],
        },
        true
      );
    });
  });
  describe("#pocket", () => {
    it("should call getPocketState when hitting NEW_TAB_REHYDRATED", () => {
      instance.getPocketState = sinon.spy();
      instance.onAction({
        type: at.NEW_TAB_REHYDRATED,
        meta: { fromTarget: {} },
      });
      assert.calledOnce(instance.getPocketState);
      assert.calledWith(instance.getPocketState, {});
    });
    it("should call dispatch in getPocketState", () => {
      const isUserLoggedIn = sinon.spy();
      globals.set("pktApi", { isUserLoggedIn });
      instance.getPocketState({});
      assert.calledOnce(instance.store.dispatch);
      const [action] = instance.store.dispatch.firstCall.args;
      assert.equal(action.type, "POCKET_LOGGED_IN");
      assert.calledOnce(isUserLoggedIn);
    });
    it("should call dispatchPocketCta when hitting onInit", async () => {
      instance.dispatchPocketCta = sinon.spy();
      await instance.onInit();
      assert.calledOnce(instance.dispatchPocketCta);
      assert.calledWith(
        instance.dispatchPocketCta,
        JSON.stringify({
          cta_button: "",
          cta_text: "",
          cta_url: "",
          use_cta: false,
        }),
        false
      );
    });
    it("should call dispatch in dispatchPocketCta", () => {
      instance.dispatchPocketCta(JSON.stringify({ use_cta: true }), false);
      assert.calledOnce(instance.store.dispatch);
      const [action] = instance.store.dispatch.firstCall.args;
      assert.equal(action.type, "POCKET_CTA");
      assert.equal(action.data.use_cta, true);
    });
    it("should call dispatchPocketCta with a pocketCta pref change", () => {
      instance.dispatchPocketCta = sinon.spy();
      instance.onAction({
        type: at.PREF_CHANGED,
        data: {
          name: "pocketCta",
          value: JSON.stringify({
            cta_button: "",
            cta_text: "",
            cta_url: "",
            use_cta: false,
          }),
        },
      });
      assert.calledOnce(instance.dispatchPocketCta);
      assert.calledWith(
        instance.dispatchPocketCta,
        JSON.stringify({
          cta_button: "",
          cta_text: "",
          cta_url: "",
          use_cta: false,
        }),
        true
      );
    });
  });
  it("should call uninit and init on disabling of showSponsored pref", async () => {
    sinon.stub(instance, "clearCache").returns(Promise.resolve());
    sinon.stub(instance, "uninit");
    sinon.stub(instance, "init");
    await instance.onAction({
      type: at.PREF_CHANGED,
      data: { name: "showSponsored", value: false },
    });
    assert.calledOnce(instance.clearCache);
    assert.calledOnce(instance.uninit);
    assert.calledOnce(instance.init);
  });
});
