tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

DiscoveryStreamFeed.test.js (117732B)


      1 import {
      2  actionCreators as ac,
      3  actionTypes as at,
      4  actionUtils as au,
      5 } from "common/Actions.mjs";
      6 import { combineReducers, createStore } from "redux";
      7 import { GlobalOverrider } from "test/unit/utils";
      8 import { DiscoveryStreamFeed } from "lib/DiscoveryStreamFeed.sys.mjs";
      9 import { RecommendationProvider } from "lib/RecommendationProvider.sys.mjs";
     10 import { reducers } from "common/Reducers.sys.mjs";
     11 
     12 import { PersistentCache } from "lib/PersistentCache.sys.mjs";
     13 import { DEFAULT_SECTION_LAYOUT } from "lib/SectionsLayoutManager.sys.mjs";
     14 
     15 const CONFIG_PREF_NAME = "discoverystream.config";
     16 const ENDPOINTS_PREF_NAME = "discoverystream.endpoints";
     17 const DUMMY_ENDPOINT = "https://getpocket.cdn.mozilla.net/dummy";
     18 const SPOC_IMPRESSION_TRACKING_PREF = "discoverystream.spoc.impressions";
     19 const THIRTY_MINUTES = 30 * 60 * 1000;
     20 const ONE_WEEK = 7 * 24 * 60 * 60 * 1000; // 1 week
     21 
     22 const FAKE_UUID = "{foo-123-foo}";
     23 
     24 const DEFAULT_COLUMN_COUNT = 4;
     25 const DEFAULT_ROW_COUNT = 6;
     26 
     27 // eslint-disable-next-line max-statements
     28 describe("DiscoveryStreamFeed", () => {
     29  let feed;
     30  let feeds;
     31  let recommendationProvider;
     32  let sandbox;
     33  let fetchStub;
     34  let clock;
     35  let fakeNewTabUtils;
     36  let globals;
     37 
     38  const setPref = (name, value) => {
     39    const action = {
     40      type: at.PREF_CHANGED,
     41      data: {
     42        name,
     43        value: typeof value === "object" ? JSON.stringify(value) : value,
     44      },
     45    };
     46    feed.store.dispatch(action);
     47    feed.onAction(action);
     48  };
     49 
     50  const stubOutFetchFromEndpointWithRealisticData = () => {
     51    sandbox.stub(feed, "fetchFromEndpoint").resolves({
     52      recommendedAt: 1755834072383,
     53      surfaceId: "NEW_TAB_EN_US",
     54      data: [
     55        {
     56          corpusItemId: "decaf-c0ff33",
     57          scheduledCorpusItemId: "matcha-latte-ff33c1",
     58          excerpt: "excerpt",
     59          iconUrl: "iconUrl",
     60          imageUrl: "imageUrl",
     61          isTimeSensitive: true,
     62          publisher: "publisher",
     63          receivedRank: 0,
     64          tileId: 12345,
     65          title: "title",
     66          topic: "topic",
     67          url: "url",
     68          features: {},
     69        },
     70        {
     71          corpusItemId: "decaf-c0ff34",
     72          scheduledCorpusItemId: "matcha-latte-ff33c2",
     73          excerpt: "excerpt",
     74          iconUrl: "iconUrl",
     75          imageUrl: "imageUrl",
     76          isTimeSensitive: true,
     77          publisher: "publisher",
     78          receivedRank: 0,
     79          tileId: 12346,
     80          title: "title",
     81          topic: "topic",
     82          url: "url",
     83          features: {},
     84        },
     85      ],
     86      settings: {
     87        recsExpireTime: 1,
     88      },
     89    });
     90  };
     91 
     92  beforeEach(() => {
     93    sandbox = sinon.createSandbox();
     94 
     95    // Fetch
     96    fetchStub = sandbox.stub(global, "fetch");
     97 
     98    // Time
     99    clock = sinon.useFakeTimers();
    100 
    101    globals = new GlobalOverrider();
    102    globals.set({
    103      gUUIDGenerator: { generateUUID: () => FAKE_UUID },
    104      PersistentCache,
    105    });
    106 
    107    sandbox
    108      .stub(global.Services.prefs, "getBoolPref")
    109      .withArgs("browser.newtabpage.activity-stream.discoverystream.enabled")
    110      .returns(true);
    111 
    112    recommendationProvider = new RecommendationProvider();
    113    recommendationProvider.store = createStore(combineReducers(reducers), {});
    114    feeds = {
    115      "feeds.recommendationprovider": recommendationProvider,
    116    };
    117 
    118    // Feed
    119    feed = new DiscoveryStreamFeed();
    120    feed.store = createStore(combineReducers(reducers), {
    121      Prefs: {
    122        values: {
    123          [CONFIG_PREF_NAME]: JSON.stringify({
    124            enabled: false,
    125          }),
    126          [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
    127          "discoverystream.enabled": true,
    128          "feeds.section.topstories": true,
    129          "feeds.system.topstories": true,
    130          "discoverystream.spocs.personalized": true,
    131          "discoverystream.recs.personalized": true,
    132          "system.showSponsored": false,
    133          "discoverystream.spocs.startupCache.enabled": true,
    134          "unifiedAds.adsFeed.enabled": false,
    135        },
    136      },
    137    });
    138    feed.store.feeds = {
    139      get: name => feeds[name],
    140    };
    141    global.fetch.resetHistory();
    142 
    143    sandbox.stub(feed, "_maybeUpdateCachedData").resolves();
    144 
    145    globals.set("setTimeout", callback => {
    146      callback();
    147    });
    148 
    149    fakeNewTabUtils = {
    150      blockedLinks: {
    151        links: [],
    152        isBlocked: () => false,
    153      },
    154      getUtcOffset: () => 0,
    155    };
    156    globals.set("NewTabUtils", fakeNewTabUtils);
    157    globals.set("ClientEnvironmentBase", {
    158      os: "0",
    159    });
    160 
    161    globals.set("ObliviousHTTP", {
    162      getOHTTPConfig: () => {},
    163      ohttpRequest: () => {},
    164    });
    165  });
    166 
    167  afterEach(() => {
    168    clock.restore();
    169    sandbox.restore();
    170    globals.restore();
    171  });
    172 
    173  describe("#fetchFromEndpoint", () => {
    174    beforeEach(() => {
    175      feed._prefCache = {
    176        config: {
    177          api_key_pref: "",
    178        },
    179      };
    180      fetchStub.resolves({
    181        json: () => Promise.resolve("hi"),
    182        ok: true,
    183      });
    184    });
    185    it("should get a response", async () => {
    186      const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
    187 
    188      assert.equal(response, "hi");
    189    });
    190    it("should not send cookies", async () => {
    191      await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
    192 
    193      assert.propertyVal(fetchStub.firstCall.args[1], "credentials", "omit");
    194    });
    195    it("should allow unexpected response", async () => {
    196      fetchStub.resolves({ ok: false });
    197 
    198      const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
    199 
    200      assert.equal(response, null);
    201    });
    202    it("should disallow unexpected endpoints", async () => {
    203      feed.store.getState = () => ({
    204        Prefs: {
    205          values: {
    206            [ENDPOINTS_PREF_NAME]: "https://other.site",
    207          },
    208        },
    209      });
    210 
    211      const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
    212 
    213      assert.equal(response, null);
    214    });
    215    it("should allow multiple endpoints", async () => {
    216      feed.store.getState = () => ({
    217        Prefs: {
    218          values: {
    219            [ENDPOINTS_PREF_NAME]: `https://other.site,${DUMMY_ENDPOINT}`,
    220          },
    221        },
    222      });
    223 
    224      const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
    225 
    226      assert.equal(response, "hi");
    227    });
    228    it("should ignore white-space added to multiple endpoints", async () => {
    229      feed.store.getState = () => ({
    230        Prefs: {
    231          values: {
    232            [ENDPOINTS_PREF_NAME]: `https://other.site, ${DUMMY_ENDPOINT}`,
    233          },
    234        },
    235      });
    236 
    237      const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
    238 
    239      assert.equal(response, "hi");
    240    });
    241    it("should allow POST and with other options", async () => {
    242      await feed.fetchFromEndpoint("https://getpocket.cdn.mozilla.net/dummy", {
    243        method: "POST",
    244        body: "{}",
    245      });
    246 
    247      assert.calledWithMatch(
    248        fetchStub,
    249        "https://getpocket.cdn.mozilla.net/dummy",
    250        {
    251          credentials: "omit",
    252          method: "POST",
    253          body: "{}",
    254        }
    255      );
    256    });
    257 
    258    it("should use OHTTP when configured and enabled", async () => {
    259      sandbox
    260        .stub(global.Services.prefs, "getStringPref")
    261        .withArgs(
    262          "browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL"
    263        )
    264        .returns("https://relay.url")
    265        .withArgs(
    266          "browser.newtabpage.activity-stream.discoverystream.ohttp.configURL"
    267        )
    268        .returns("https://config.url");
    269 
    270      const fakeOhttpConfig = { config: "config" };
    271      sandbox
    272        .stub(global.ObliviousHTTP, "getOHTTPConfig")
    273        .resolves(fakeOhttpConfig);
    274 
    275      const ohttpResponse = {
    276        json: () => Promise.resolve("ohttp response"),
    277        ok: true,
    278      };
    279      const ohttpRequestStub = sandbox
    280        .stub(global.ObliviousHTTP, "ohttpRequest")
    281        .resolves(ohttpResponse);
    282 
    283      // Allow the endpoint
    284      feed.store.getState = () => ({
    285        Prefs: {
    286          values: {
    287            [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
    288          },
    289        },
    290      });
    291 
    292      const result = await feed.fetchFromEndpoint(DUMMY_ENDPOINT, {}, true);
    293 
    294      assert.equal(result, "ohttp response");
    295      assert.calledOnce(ohttpRequestStub);
    296      assert.calledWithMatch(
    297        ohttpRequestStub,
    298        "https://relay.url",
    299        fakeOhttpConfig,
    300        DUMMY_ENDPOINT
    301      );
    302    });
    303 
    304    it("should cast headers from a Headers object to JS object when using OHTTP", async () => {
    305      sandbox
    306        .stub(global.Services.prefs, "getStringPref")
    307        .withArgs(
    308          "browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL"
    309        )
    310        .returns("https://relay.url")
    311        .withArgs(
    312          "browser.newtabpage.activity-stream.discoverystream.ohttp.configURL"
    313        )
    314        .returns("https://config.url");
    315 
    316      const fakeOhttpConfig = { config: "config" };
    317      sandbox
    318        .stub(global.ObliviousHTTP, "getOHTTPConfig")
    319        .resolves(fakeOhttpConfig);
    320 
    321      const ohttpResponse = {
    322        json: () => Promise.resolve("ohttp response"),
    323        ok: true,
    324      };
    325      const ohttpRequestStub = sandbox
    326        .stub(global.ObliviousHTTP, "ohttpRequest")
    327        .resolves(ohttpResponse);
    328 
    329      // Allow the endpoint
    330      feed.store.getState = () => ({
    331        Prefs: {
    332          values: {
    333            [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
    334          },
    335        },
    336      });
    337 
    338      const headers = new Headers();
    339      headers.set("headername", "headervalue");
    340 
    341      const result = await feed.fetchFromEndpoint(
    342        DUMMY_ENDPOINT,
    343        { headers },
    344        true
    345      );
    346 
    347      assert.equal(result, "ohttp response");
    348      assert.calledOnce(ohttpRequestStub);
    349      assert.calledWithMatch(
    350        ohttpRequestStub,
    351        "https://relay.url",
    352        fakeOhttpConfig,
    353        DUMMY_ENDPOINT,
    354        { headers: Object.fromEntries(headers), credentials: "omit" }
    355      );
    356    });
    357  });
    358 
    359  describe("#getOrCreateImpressionId", () => {
    360    it("should create impression id in constructor", async () => {
    361      assert.equal(feed._impressionId, FAKE_UUID);
    362    });
    363    it("should create impression id if none exists", async () => {
    364      sandbox.stub(global.Services.prefs, "getCharPref").returns("");
    365      sandbox.stub(global.Services.prefs, "setCharPref").returns();
    366 
    367      const result = feed.getOrCreateImpressionId();
    368 
    369      assert.equal(result, FAKE_UUID);
    370      assert.calledOnce(global.Services.prefs.setCharPref);
    371    });
    372    it("should use impression id if exists", async () => {
    373      sandbox.stub(global.Services.prefs, "getCharPref").returns("from get");
    374 
    375      const result = feed.getOrCreateImpressionId();
    376 
    377      assert.equal(result, "from get");
    378      assert.calledOnce(global.Services.prefs.getCharPref);
    379    });
    380  });
    381 
    382  describe("#parseGridPositions", () => {
    383    it("should return an equivalent array for an array of non negative integers", async () => {
    384      assert.deepEqual(feed.parseGridPositions([0, 2, 3]), [0, 2, 3]);
    385    });
    386    it("should return undefined for an array containing negative integers", async () => {
    387      assert.equal(feed.parseGridPositions([-2, 2, 3]), undefined);
    388    });
    389    it("should return undefined for an undefined input", async () => {
    390      assert.equal(feed.parseGridPositions(undefined), undefined);
    391    });
    392  });
    393 
    394  describe("#loadLayout", () => {
    395    it("should use local basic layout with hardcoded_basic_layout being true", async () => {
    396      feed.config.hardcoded_basic_layout = true;
    397 
    398      await feed.loadLayout(feed.store.dispatch);
    399 
    400      assert.equal(
    401        feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
    402        "https://spocs.getpocket.com/spocs"
    403      );
    404      const { layout } = feed.store.getState().DiscoveryStream;
    405      assert.equal(
    406        layout[0].components[2].properties.items,
    407        DEFAULT_COLUMN_COUNT
    408      );
    409    });
    410    it("should use 1 row layout if specified", async () => {
    411      feed.store = createStore(combineReducers(reducers), {
    412        Prefs: {
    413          values: {
    414            [CONFIG_PREF_NAME]: JSON.stringify({
    415              enabled: true,
    416            }),
    417            [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
    418            "discoverystream.enabled": true,
    419            "discoverystream.region-basic-layout": true,
    420            "system.showSponsored": false,
    421          },
    422        },
    423      });
    424 
    425      await feed.loadLayout(feed.store.dispatch);
    426 
    427      const { layout } = feed.store.getState().DiscoveryStream;
    428      assert.equal(
    429        layout[0].components[2].properties.items,
    430        DEFAULT_COLUMN_COUNT
    431      );
    432    });
    433    it("should use 6 row layout if specified", async () => {
    434      feed.store = createStore(combineReducers(reducers), {
    435        Prefs: {
    436          values: {
    437            [CONFIG_PREF_NAME]: JSON.stringify({
    438              enabled: true,
    439            }),
    440            [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
    441            "discoverystream.enabled": true,
    442            "discoverystream.region-basic-layout": false,
    443            "system.showSponsored": false,
    444          },
    445        },
    446      });
    447 
    448      await feed.loadLayout(feed.store.dispatch);
    449 
    450      const { layout } = feed.store.getState().DiscoveryStream;
    451      assert.equal(
    452        layout[0].components[2].properties.items,
    453        DEFAULT_ROW_COUNT * DEFAULT_COLUMN_COUNT
    454      );
    455    });
    456    it("should use new spocs endpoint if in the config", async () => {
    457      feed.config.spocs_endpoint = "https://spocs.getpocket.com/spocs2";
    458 
    459      await feed.loadLayout(feed.store.dispatch);
    460 
    461      assert.equal(
    462        feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
    463        "https://spocs.getpocket.com/spocs2"
    464      );
    465    });
    466    it("should use local basic layout with FF pref hardcoded_basic_layout", async () => {
    467      feed.store = createStore(combineReducers(reducers), {
    468        Prefs: {
    469          values: {
    470            [CONFIG_PREF_NAME]: JSON.stringify({
    471              enabled: false,
    472            }),
    473            [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
    474            "discoverystream.enabled": true,
    475            "discoverystream.hardcoded-basic-layout": true,
    476            "system.showSponsored": false,
    477          },
    478        },
    479      });
    480 
    481      await feed.loadLayout(feed.store.dispatch);
    482 
    483      assert.equal(
    484        feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
    485        "https://spocs.getpocket.com/spocs"
    486      );
    487      const { layout } = feed.store.getState().DiscoveryStream;
    488      assert.equal(
    489        layout[0].components[2].properties.items,
    490        DEFAULT_COLUMN_COUNT
    491      );
    492    });
    493    it("should use new spocs endpoint if in a FF pref", async () => {
    494      feed.store = createStore(combineReducers(reducers), {
    495        Prefs: {
    496          values: {
    497            [CONFIG_PREF_NAME]: JSON.stringify({
    498              enabled: false,
    499            }),
    500            [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
    501            "discoverystream.enabled": true,
    502            "discoverystream.spocs-endpoint":
    503              "https://spocs.getpocket.com/spocs2",
    504            "system.showSponsored": false,
    505          },
    506        },
    507      });
    508 
    509      await feed.loadLayout(feed.store.dispatch);
    510 
    511      assert.equal(
    512        feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
    513        "https://spocs.getpocket.com/spocs2"
    514      );
    515    });
    516    it("should return enough stories to fill a four card layout", async () => {
    517      feed.store = createStore(combineReducers(reducers), {
    518        Prefs: {
    519          values: {
    520            pocketConfig: { fourCardLayout: true },
    521          },
    522        },
    523      });
    524 
    525      await feed.loadLayout(feed.store.dispatch);
    526 
    527      const { layout } = feed.store.getState().DiscoveryStream;
    528      assert.equal(
    529        layout[0].components[2].properties.items,
    530        DEFAULT_ROW_COUNT * DEFAULT_COLUMN_COUNT
    531      );
    532    });
    533    it("should create a layout with spoc and widget positions", async () => {
    534      feed.store = createStore(combineReducers(reducers), {
    535        Prefs: {
    536          values: {
    537            "discoverystream.spoc-positions": "1, 2",
    538            pocketConfig: {
    539              widgetPositions: "3, 4",
    540            },
    541          },
    542        },
    543      });
    544 
    545      await feed.loadLayout(feed.store.dispatch);
    546 
    547      const { layout } = feed.store.getState().DiscoveryStream;
    548      assert.deepEqual(layout[0].components[2].spocs.positions, [
    549        { index: 1 },
    550        { index: 2 },
    551      ]);
    552      assert.deepEqual(layout[0].components[2].widgets.positions, [
    553        { index: 3 },
    554        { index: 4 },
    555      ]);
    556    });
    557    it("should create a layout with spoc position data", async () => {
    558      feed.store = createStore(combineReducers(reducers), {
    559        Prefs: {
    560          values: {
    561            pocketConfig: {
    562              spocAdTypes: "1230",
    563              spocZoneIds: "4560, 7890",
    564            },
    565          },
    566        },
    567      });
    568 
    569      await feed.loadLayout(feed.store.dispatch);
    570 
    571      const { layout } = feed.store.getState().DiscoveryStream;
    572      assert.deepEqual(layout[0].components[2].placement.ad_types, [1230]);
    573      assert.deepEqual(
    574        layout[0].components[2].placement.zone_ids,
    575        [4560, 7890]
    576      );
    577    });
    578    it("should create a layout with proper spoc url with a site id", async () => {
    579      feed.store = createStore(combineReducers(reducers), {
    580        Prefs: {
    581          values: {
    582            pocketConfig: {
    583              spocSiteId: "1234",
    584            },
    585          },
    586        },
    587      });
    588 
    589      await feed.loadLayout(feed.store.dispatch);
    590      const { spocs } = feed.store.getState().DiscoveryStream;
    591      assert.deepEqual(
    592        spocs.spocs_endpoint,
    593        "https://spocs.getpocket.com/spocs?site=1234"
    594      );
    595    });
    596  });
    597 
    598  describe("#updatePlacements", () => {
    599    it("should dispatch DISCOVERY_STREAM_SPOCS_PLACEMENTS", () => {
    600      sandbox.spy(feed.store, "dispatch");
    601      feed.store.getState = () => ({
    602        Prefs: {
    603          values: { showSponsored: true, "system.showSponsored": true },
    604        },
    605      });
    606      const fakeComponents = {
    607        components: [
    608          { placement: { name: "first" }, spocs: {} },
    609          { placement: { name: "second" }, spocs: {} },
    610        ],
    611      };
    612      const fakeLayout = [fakeComponents];
    613 
    614      feed.updatePlacements(feed.store.dispatch, fakeLayout);
    615 
    616      assert.calledOnce(feed.store.dispatch);
    617      assert.calledWith(feed.store.dispatch, {
    618        type: "DISCOVERY_STREAM_SPOCS_PLACEMENTS",
    619        data: { placements: [{ name: "first" }, { name: "second" }] },
    620        meta: { isStartup: false },
    621      });
    622    });
    623    it("should fire update placements from loadLayout", async () => {
    624      sandbox.spy(feed, "updatePlacements");
    625 
    626      await feed.loadLayout(feed.store.dispatch);
    627 
    628      assert.calledOnce(feed.updatePlacements);
    629    });
    630  });
    631 
    632  describe("#placementsForEach", () => {
    633    it("should forEach through placements", () => {
    634      feed.store.getState = () => ({
    635        DiscoveryStream: {
    636          spocs: {
    637            placements: [{ name: "first" }, { name: "second" }],
    638          },
    639        },
    640      });
    641 
    642      let items = [];
    643 
    644      feed.placementsForEach(item => items.push(item.name));
    645 
    646      assert.deepEqual(items, ["first", "second"]);
    647    });
    648  });
    649 
    650  describe("#loadComponentFeeds", () => {
    651    let fakeCache;
    652    let fakeDiscoveryStream;
    653    beforeEach(() => {
    654      fakeDiscoveryStream = {
    655        Prefs: {
    656          values: {
    657            "discoverystream.spocs.startupCache.enabled": true,
    658          },
    659        },
    660        DiscoveryStream: {
    661          layout: [
    662            { components: [{ feed: { url: "foo.com" } }] },
    663            { components: [{}] },
    664            {},
    665          ],
    666        },
    667      };
    668      fakeCache = {};
    669      sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
    670      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
    671    });
    672 
    673    afterEach(() => {
    674      sandbox.restore();
    675    });
    676 
    677    it("should not dispatch updates when layout is not defined", async () => {
    678      fakeDiscoveryStream = {
    679        DiscoveryStream: {},
    680      };
    681      feed.store.getState.returns(fakeDiscoveryStream);
    682      sandbox.spy(feed.store, "dispatch");
    683 
    684      await feed.loadComponentFeeds(feed.store.dispatch);
    685 
    686      assert.notCalled(feed.store.dispatch);
    687    });
    688 
    689    it("should populate feeds cache", async () => {
    690      fakeCache = {
    691        feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } },
    692      };
    693      sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
    694 
    695      await feed.loadComponentFeeds(feed.store.dispatch);
    696 
    697      assert.calledWith(feed.cache.set, "feeds", {
    698        "foo.com": { data: "data", lastUpdated: 0 },
    699      });
    700    });
    701 
    702    it("should send feed update events with new feed data", async () => {
    703      sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
    704      sandbox.spy(feed.store, "dispatch");
    705      feed._prefCache = {
    706        config: {
    707          api_key_pref: "",
    708        },
    709      };
    710 
    711      await feed.loadComponentFeeds(feed.store.dispatch);
    712 
    713      assert.calledWith(feed.store.dispatch.firstCall, {
    714        type: at.DISCOVERY_STREAM_FEED_UPDATE,
    715        data: { feed: { data: { status: "failed" } }, url: "foo.com" },
    716        meta: { isStartup: false },
    717      });
    718      assert.calledWith(feed.store.dispatch.secondCall, {
    719        type: at.DISCOVERY_STREAM_FEEDS_UPDATE,
    720        meta: { isStartup: false },
    721      });
    722    });
    723 
    724    it("should return number of promises equal to unique urls", async () => {
    725      sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
    726      sandbox.stub(global.Promise, "all").resolves();
    727      fakeDiscoveryStream = {
    728        DiscoveryStream: {
    729          layout: [
    730            {
    731              components: [
    732                { feed: { url: "foo.com" } },
    733                { feed: { url: "bar.com" } },
    734              ],
    735            },
    736            { components: [{ feed: { url: "foo.com" } }] },
    737            {},
    738            { components: [{ feed: { url: "baz.com" } }] },
    739          ],
    740        },
    741      };
    742      feed.store.getState.returns(fakeDiscoveryStream);
    743 
    744      await feed.loadComponentFeeds(feed.store.dispatch);
    745 
    746      assert.calledOnce(global.Promise.all);
    747      const { args } = global.Promise.all.firstCall;
    748      assert.equal(args[0].length, 3);
    749    });
    750  });
    751 
    752  describe("#getComponentFeed", () => {
    753    it("should fetch fresh feed data if cache is empty", async () => {
    754      const fakeCache = {};
    755      sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
    756      sandbox.stub(feed, "rotate").callsFake(val => val);
    757      sandbox
    758        .stub(feed, "scoreItems")
    759        .callsFake(val => ({ data: val, filtered: [], personalized: false }));
    760      stubOutFetchFromEndpointWithRealisticData();
    761 
    762      const feedResp = await feed.getComponentFeed("foo.com");
    763      assert.equal(feedResp.data.recommendations.length, 2);
    764    });
    765    it("should fetch fresh feed data if cache is old", async () => {
    766      const fakeCache = { feeds: { "foo.com": { lastUpdated: Date.now() } } };
    767      sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
    768      stubOutFetchFromEndpointWithRealisticData();
    769      sandbox.stub(feed, "rotate").callsFake(val => val);
    770      sandbox
    771        .stub(feed, "scoreItems")
    772        .callsFake(val => ({ data: val, filtered: [], personalized: false }));
    773      clock.tick(THIRTY_MINUTES + 1);
    774 
    775      const feedResp = await feed.getComponentFeed("foo.com");
    776 
    777      assert.equal(feedResp.data.recommendations.length, 2);
    778    });
    779    it("should return feed data from cache if it is fresh", async () => {
    780      const fakeCache = {
    781        feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } },
    782      };
    783      sandbox.stub(feed.cache, "get").resolves(fakeCache);
    784      sandbox.stub(feed, "fetchFromEndpoint").resolves("old data");
    785      clock.tick(THIRTY_MINUTES - 1);
    786 
    787      const feedResp = await feed.getComponentFeed("foo.com");
    788 
    789      assert.equal(feedResp.data, "data");
    790    });
    791    it("should return null if no response was received", async () => {
    792      sandbox.stub(feed, "fetchFromEndpoint").resolves(null);
    793 
    794      const feedResp = await feed.getComponentFeed("foo.com");
    795 
    796      assert.deepEqual(feedResp, { data: { status: "failed" } });
    797    });
    798  });
    799 
    800  describe("#loadSpocs", () => {
    801    beforeEach(() => {
    802      feed._prefCache = {
    803        config: {
    804          api_key_pref: "",
    805        },
    806      };
    807 
    808      sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
    809      Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
    810    });
    811    it("should not fetch or update cache if no spocs endpoint is defined", async () => {
    812      feed.store.dispatch(
    813        ac.BroadcastToContent({
    814          type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT,
    815          data: "",
    816        })
    817      );
    818 
    819      sandbox.spy(feed.cache, "set");
    820 
    821      await feed.loadSpocs(feed.store.dispatch);
    822 
    823      assert.notCalled(global.fetch);
    824      assert.calledWith(feed.cache.set, "spocs", {
    825        lastUpdated: 0,
    826        spocs: {},
    827        spocsOnDemand: undefined,
    828        spocsCacheUpdateTime: 30 * 60 * 1000,
    829      });
    830    });
    831    it("should fetch fresh spocs data if cache is empty", async () => {
    832      sandbox.stub(feed.cache, "get").returns(Promise.resolve());
    833      sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "data" });
    834      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
    835 
    836      await feed.loadSpocs(feed.store.dispatch);
    837 
    838      assert.calledWith(feed.cache.set, "spocs", {
    839        spocs: { placement: "data" },
    840        lastUpdated: 0,
    841        spocsOnDemand: undefined,
    842        spocsCacheUpdateTime: 30 * 60 * 1000,
    843      });
    844      assert.equal(
    845        feed.store.getState().DiscoveryStream.spocs.data.placement,
    846        "data"
    847      );
    848    });
    849    it("should fetch fresh data if cache is old", async () => {
    850      const cachedSpoc = {
    851        spocs: { placement: "old" },
    852        lastUpdated: Date.now(),
    853      };
    854      const cachedData = { spocs: cachedSpoc };
    855      sandbox.stub(feed.cache, "get").returns(Promise.resolve(cachedData));
    856      sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "new" });
    857      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
    858      clock.tick(THIRTY_MINUTES + 1);
    859 
    860      await feed.loadSpocs(feed.store.dispatch);
    861 
    862      assert.equal(
    863        feed.store.getState().DiscoveryStream.spocs.data.placement,
    864        "new"
    865      );
    866    });
    867    it("should return spoc data from cache if it is fresh", async () => {
    868      const cachedSpoc = {
    869        spocs: { placement: "old" },
    870        lastUpdated: Date.now(),
    871      };
    872      const cachedData = { spocs: cachedSpoc };
    873      sandbox.stub(feed.cache, "get").returns(Promise.resolve(cachedData));
    874      sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "new" });
    875      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
    876      clock.tick(THIRTY_MINUTES - 1);
    877 
    878      await feed.loadSpocs(feed.store.dispatch);
    879 
    880      assert.equal(
    881        feed.store.getState().DiscoveryStream.spocs.data.placement,
    882        "old"
    883      );
    884    });
    885    it("should properly transform spocs using placements", async () => {
    886      sandbox.stub(feed.cache, "get").returns(Promise.resolve());
    887      sandbox.stub(feed, "fetchFromEndpoint").resolves({
    888        spocs: { items: [{ id: "data" }] },
    889      });
    890      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
    891      const loadTimestamp = 100;
    892      clock.tick(loadTimestamp);
    893 
    894      await feed.loadSpocs(feed.store.dispatch);
    895 
    896      assert.calledWith(feed.cache.set, "spocs", {
    897        spocs: {
    898          spocs: {
    899            personalized: false,
    900            context: "",
    901            title: "",
    902            sponsor: "",
    903            sponsored_by_override: undefined,
    904            items: [{ id: "data", score: 1, fetchTimestamp: loadTimestamp }],
    905          },
    906        },
    907        lastUpdated: loadTimestamp,
    908        spocsOnDemand: undefined,
    909        spocsCacheUpdateTime: 30 * 60 * 1000,
    910      });
    911 
    912      assert.deepEqual(
    913        feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0],
    914        { id: "data", score: 1, fetchTimestamp: loadTimestamp }
    915      );
    916    });
    917    it("should normalizeSpocsItems for older spoc data", async () => {
    918      sandbox.stub(feed.cache, "get").returns(Promise.resolve());
    919      sandbox
    920        .stub(feed, "fetchFromEndpoint")
    921        .resolves({ spocs: [{ id: "data" }] });
    922      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
    923 
    924      await feed.loadSpocs(feed.store.dispatch);
    925 
    926      assert.deepEqual(
    927        feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0],
    928        { id: "data", score: 1, fetchTimestamp: 0 }
    929      );
    930    });
    931    it("should dispatch DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE with feature_flags", async () => {
    932      sandbox.stub(feed.cache, "get").returns(Promise.resolve());
    933      sandbox.spy(feed.store, "dispatch");
    934      sandbox
    935        .stub(feed, "fetchFromEndpoint")
    936        .resolves({ settings: { feature_flags: {} }, spocs: [{ id: "data" }] });
    937      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
    938 
    939      await feed.loadSpocs(feed.store.dispatch);
    940 
    941      assert.calledWith(
    942        feed.store.dispatch,
    943        ac.OnlyToMain({
    944          type: at.DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE,
    945          data: {
    946            override: true,
    947          },
    948        })
    949      );
    950    });
    951    it("should return expected data if normalizeSpocsItems returns no spoc data", async () => {
    952      // We don't need this for just this test, we are setting placements
    953      // manually.
    954      feed.getPlacements.restore();
    955 
    956      sandbox.stub(feed.cache, "get").returns(Promise.resolve());
    957      sandbox
    958        .stub(feed, "fetchFromEndpoint")
    959        .resolves({ placement1: [{ id: "data" }], placement2: [] });
    960      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
    961 
    962      const fakeComponents = {
    963        components: [
    964          { placement: { name: "placement1" }, spocs: {} },
    965          { placement: { name: "placement2" }, spocs: {} },
    966        ],
    967      };
    968      feed.updatePlacements(feed.store.dispatch, [fakeComponents]);
    969 
    970      await feed.loadSpocs(feed.store.dispatch);
    971 
    972      assert.deepEqual(feed.store.getState().DiscoveryStream.spocs.data, {
    973        placement1: {
    974          personalized: false,
    975          title: "",
    976          context: "",
    977          sponsor: "",
    978          sponsored_by_override: undefined,
    979          items: [{ id: "data", score: 1, fetchTimestamp: 0 }],
    980        },
    981        placement2: {
    982          title: "",
    983          context: "",
    984          items: [],
    985        },
    986      });
    987    });
    988    it("should use title and context on spoc data", async () => {
    989      // We don't need this for just this test, we are setting placements
    990      // manually.
    991      feed.getPlacements.restore();
    992      sandbox.stub(feed.cache, "get").returns(Promise.resolve());
    993      sandbox.stub(feed, "fetchFromEndpoint").resolves({
    994        placement1: {
    995          title: "title",
    996          context: "context",
    997          sponsor: "",
    998          sponsored_by_override: undefined,
    999          items: [{ id: "data" }],
   1000        },
   1001      });
   1002      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
   1003 
   1004      const fakeComponents = {
   1005        components: [{ placement: { name: "placement1" }, spocs: {} }],
   1006      };
   1007      feed.updatePlacements(feed.store.dispatch, [fakeComponents]);
   1008 
   1009      await feed.loadSpocs(feed.store.dispatch);
   1010 
   1011      assert.deepEqual(feed.store.getState().DiscoveryStream.spocs.data, {
   1012        placement1: {
   1013          personalized: false,
   1014          title: "title",
   1015          context: "context",
   1016          sponsor: "",
   1017          sponsored_by_override: undefined,
   1018          items: [{ id: "data", score: 1, fetchTimestamp: 0 }],
   1019        },
   1020      });
   1021    });
   1022    it("should fetch MARS pre flight info", async () => {
   1023      sandbox
   1024        .stub(feed, "fetchFromEndpoint")
   1025        .withArgs("unifiedAdEndpoint/v1/ads-preflight", { method: "GET" })
   1026        .resolves({
   1027          normalized_ua: "normalized_ua",
   1028          geoname_id: "geoname_id",
   1029          geo_location: "geo_location",
   1030        });
   1031 
   1032      feed.store = createStore(combineReducers(reducers), {
   1033        Prefs: {
   1034          values: {
   1035            "unifiedAds.endpoint": "unifiedAdEndpoint/",
   1036            "unifiedAds.blockedAds": "",
   1037            "unifiedAds.spocs.enabled": true,
   1038            "discoverystream.placements.spocs": "newtab_stories_1",
   1039            "discoverystream.placements.spocs.counts": "1",
   1040            "unifiedAds.ohttp.enabled": true,
   1041          },
   1042        },
   1043      });
   1044 
   1045      await feed.loadSpocs(feed.store.dispatch);
   1046 
   1047      assert.equal(
   1048        feed.fetchFromEndpoint.firstCall.args[0],
   1049        "unifiedAdEndpoint/v1/ads-preflight"
   1050      );
   1051      assert.equal(feed.fetchFromEndpoint.firstCall.args[1].method, "GET");
   1052      assert.equal(
   1053        feed.fetchFromEndpoint.secondCall.args[0],
   1054        "unifiedAdEndpoint/v1/ads"
   1055      );
   1056      assert.equal(
   1057        feed.fetchFromEndpoint.secondCall.args[1].headers.get("X-User-Agent"),
   1058        "normalized_ua"
   1059      );
   1060      assert.equal(
   1061        feed.fetchFromEndpoint.secondCall.args[1].headers.get("X-Geoname-ID"),
   1062        "geoname_id"
   1063      );
   1064      assert.equal(
   1065        feed.fetchFromEndpoint.secondCall.args[1].headers.get("X-Geo-Location"),
   1066        "geo_location"
   1067      );
   1068    });
   1069  });
   1070 
   1071  describe("#normalizeSpocsItems", () => {
   1072    it("should return correct data if new data passed in", async () => {
   1073      const spocs = {
   1074        title: "title",
   1075        context: "context",
   1076        sponsor: "sponsor",
   1077        sponsored_by_override: "override",
   1078        items: [{ id: "id" }],
   1079      };
   1080      const result = feed.normalizeSpocsItems(spocs);
   1081      assert.deepEqual(result, spocs);
   1082    });
   1083    it("should return normalized data if new data passed in without title or context", async () => {
   1084      const spocs = {
   1085        items: [{ id: "id" }],
   1086      };
   1087      const result = feed.normalizeSpocsItems(spocs);
   1088      assert.deepEqual(result, {
   1089        title: "",
   1090        context: "",
   1091        sponsor: "",
   1092        sponsored_by_override: undefined,
   1093        items: [{ id: "id" }],
   1094      });
   1095    });
   1096    it("should return normalized data if old data passed in", async () => {
   1097      const spocs = [{ id: "id" }];
   1098      const result = feed.normalizeSpocsItems(spocs);
   1099      assert.deepEqual(result, {
   1100        title: "",
   1101        context: "",
   1102        sponsor: "",
   1103        sponsored_by_override: undefined,
   1104        items: [{ id: "id" }],
   1105      });
   1106    });
   1107  });
   1108 
   1109  describe("#showSponsoredStories", () => {
   1110    it("should return false from showSponsoredStories if user pref showSponsored is false", async () => {
   1111      feed.store.getState = () => ({
   1112        Prefs: {
   1113          values: { showSponsored: false, "system.showSponsored": true },
   1114        },
   1115      });
   1116 
   1117      assert.isFalse(feed.showSponsoredStories);
   1118    });
   1119    it("should return false from showSponsoredStories if DiscoveryStream pref system.showSponsored is false", async () => {
   1120      feed.store.getState = () => ({
   1121        Prefs: {
   1122          values: { showSponsored: true, "system.showSponsored": false },
   1123        },
   1124      });
   1125 
   1126      assert.isFalse(feed.showSponsoredStories);
   1127    });
   1128    it("should return true from showSponsoredStories if both prefs are true", async () => {
   1129      feed.store.getState = () => ({
   1130        Prefs: {
   1131          values: { showSponsored: true, "system.showSponsored": true },
   1132        },
   1133      });
   1134 
   1135      assert.isTrue(feed.showSponsoredStories);
   1136    });
   1137  });
   1138 
   1139  describe("#showStories", () => {
   1140    it("should return false from showStories if user pref is false", async () => {
   1141      feed.store.getState = () => ({
   1142        Prefs: {
   1143          values: {
   1144            "feeds.section.topstories": false,
   1145            "feeds.system.topstories": true,
   1146          },
   1147        },
   1148      });
   1149      assert.isFalse(feed.showStories);
   1150    });
   1151    it("should return false from showStories if system pref is false", async () => {
   1152      feed.store.getState = () => ({
   1153        Prefs: {
   1154          values: {
   1155            "feeds.section.topstories": true,
   1156            "feeds.system.topstories": false,
   1157          },
   1158        },
   1159      });
   1160      assert.isFalse(feed.showStories);
   1161    });
   1162    it("should return true from showStories if both prefs are true", async () => {
   1163      feed.store.getState = () => ({
   1164        Prefs: {
   1165          values: {
   1166            "feeds.section.topstories": true,
   1167            "feeds.system.topstories": true,
   1168          },
   1169        },
   1170      });
   1171      assert.isTrue(feed.showStories);
   1172    });
   1173  });
   1174 
   1175  describe("#clearSpocs", () => {
   1176    let defaultState;
   1177    let DiscoveryStream;
   1178    let Prefs;
   1179    beforeEach(() => {
   1180      DiscoveryStream = {
   1181        layout: [],
   1182      };
   1183      Prefs = {
   1184        values: {
   1185          "feeds.section.topstories": true,
   1186          "feeds.system.topstories": true,
   1187          showSponsored: true,
   1188          "system.showSponsored": true,
   1189        },
   1190      };
   1191      defaultState = {
   1192        DiscoveryStream,
   1193        Prefs,
   1194      };
   1195      feed.store.getState = () => defaultState;
   1196    });
   1197    it("should not fail with no endpoint", async () => {
   1198      sandbox.stub(feed.store, "getState").returns({
   1199        Prefs: {
   1200          values: { PREF_SPOCS_CLEAR_ENDPOINT: null },
   1201        },
   1202      });
   1203      sandbox.stub(feed, "fetchFromEndpoint").resolves(null);
   1204 
   1205      await feed.clearSpocs();
   1206 
   1207      assert.notCalled(feed.fetchFromEndpoint);
   1208    });
   1209    it("should call DELETE with endpoint", async () => {
   1210      sandbox.stub(feed.store, "getState").returns({
   1211        Prefs: {
   1212          values: {
   1213            "discoverystream.endpointSpocsClear": "https://spocs/user",
   1214          },
   1215        },
   1216      });
   1217      sandbox.stub(feed, "fetchFromEndpoint").resolves(null);
   1218      feed._impressionId = "1234";
   1219 
   1220      await feed.clearSpocs();
   1221 
   1222      assert.equal(
   1223        feed.fetchFromEndpoint.firstCall.args[0],
   1224        "https://spocs/user"
   1225      );
   1226      assert.equal(feed.fetchFromEndpoint.firstCall.args[1].method, "DELETE");
   1227      assert.equal(
   1228        feed.fetchFromEndpoint.firstCall.args[1].body,
   1229        '{"pocket_id":"1234"}'
   1230      );
   1231    });
   1232    it("should properly call clearSpocs when sponsored content is changed", async () => {
   1233      sandbox.stub(feed, "clearSpocs").returns(Promise.resolve());
   1234      sandbox.stub(feed, "loadSpocs").returns();
   1235 
   1236      await feed.onAction({
   1237        type: at.PREF_CHANGED,
   1238        data: { name: "showSponsored" },
   1239      });
   1240 
   1241      assert.notCalled(feed.clearSpocs);
   1242 
   1243      Prefs.values.showSponsored = false;
   1244 
   1245      await feed.onAction({
   1246        type: at.PREF_CHANGED,
   1247        data: { name: "showSponsored" },
   1248      });
   1249 
   1250      assert.calledOnce(feed.clearSpocs);
   1251    });
   1252    it("should call clearSpocs when top stories are turned off", async () => {
   1253      sandbox.stub(feed, "clearSpocs").returns(Promise.resolve());
   1254      Prefs.values["feeds.section.topstories"] = false;
   1255 
   1256      await feed.onAction({
   1257        type: at.PREF_CHANGED,
   1258        data: { name: "feeds.section.topstories" },
   1259      });
   1260 
   1261      assert.calledOnce(feed.clearSpocs);
   1262    });
   1263  });
   1264 
   1265  describe("#rotate", () => {
   1266    it("should move seen first story to the back of the response", async () => {
   1267      const feedResponse = {
   1268        recommendations: [
   1269          {
   1270            id: "first",
   1271          },
   1272          {
   1273            id: "second",
   1274          },
   1275          {
   1276            id: "third",
   1277          },
   1278          {
   1279            id: "fourth",
   1280          },
   1281        ],
   1282      };
   1283      const fakeImpressions = {
   1284        first: Date.now() - 60 * 60 * 1000, // 1 hour
   1285        third: Date.now(),
   1286      };
   1287      const cache = {
   1288        recsImpressions: fakeImpressions,
   1289      };
   1290      sandbox.stub(feed.cache, "get").returns(Promise.resolve());
   1291      feed.cache.get.resolves(cache);
   1292 
   1293      const result = await feed.rotate(feedResponse.recommendations);
   1294 
   1295      assert.equal(result[3].id, "first");
   1296    });
   1297  });
   1298 
   1299  describe("#reset", () => {
   1300    it("should fire all reset based functions", async () => {
   1301      sandbox.stub(global.Services.obs, "removeObserver").returns();
   1302 
   1303      sandbox.stub(feed, "resetDataPrefs").returns();
   1304      sandbox.stub(feed, "resetCache").returns(Promise.resolve());
   1305      sandbox.stub(feed, "resetState").returns();
   1306 
   1307      feed.loaded = true;
   1308 
   1309      await feed.reset();
   1310 
   1311      assert.calledOnce(feed.resetDataPrefs);
   1312      assert.calledOnce(feed.resetCache);
   1313      assert.calledOnce(feed.resetState);
   1314    });
   1315  });
   1316 
   1317  describe("#resetCache", () => {
   1318    it("should set .feeds and .spocs and to {}", async () => {
   1319      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
   1320 
   1321      await feed.resetCache();
   1322 
   1323      assert.callCount(feed.cache.set, 3);
   1324      const firstCall = feed.cache.set.getCall(0);
   1325      const secondCall = feed.cache.set.getCall(1);
   1326      const thirdCall = feed.cache.set.getCall(2);
   1327      assert.deepEqual(firstCall.args, ["feeds", {}]);
   1328      assert.deepEqual(secondCall.args, ["spocs", {}]);
   1329      assert.deepEqual(thirdCall.args, ["recsImpressions", {}]);
   1330    });
   1331  });
   1332 
   1333  describe("#scoreItems", () => {
   1334    it("should return initial data from scoreItems if spocs are empty", async () => {
   1335      const { data: result } = await feed.scoreItems([]);
   1336 
   1337      assert.equal(result.length, 0);
   1338    });
   1339 
   1340    it("should sort based on item_score", async () => {
   1341      const { data: result } = await feed.scoreItems([
   1342        { id: 2, flight_id: 2, item_score: 0.8 },
   1343        { id: 4, flight_id: 4, item_score: 0.5 },
   1344        { id: 3, flight_id: 3, item_score: 0.7 },
   1345        { id: 1, flight_id: 1, item_score: 0.9 },
   1346      ]);
   1347 
   1348      assert.deepEqual(result, [
   1349        { id: 1, flight_id: 1, item_score: 0.9, score: 0.9 },
   1350        { id: 2, flight_id: 2, item_score: 0.8, score: 0.8 },
   1351        { id: 3, flight_id: 3, item_score: 0.7, score: 0.7 },
   1352        { id: 4, flight_id: 4, item_score: 0.5, score: 0.5 },
   1353      ]);
   1354    });
   1355 
   1356    it("should sort based on priority", async () => {
   1357      const { data: result } = await feed.scoreItems([
   1358        { id: 6, flight_id: 6, priority: 2, item_score: 0.7 },
   1359        { id: 2, flight_id: 3, priority: 1, item_score: 0.2 },
   1360        { id: 4, flight_id: 4, item_score: 0.6 },
   1361        { id: 5, flight_id: 5, priority: 2, item_score: 0.8 },
   1362        { id: 3, flight_id: 3, item_score: 0.8 },
   1363        { id: 1, flight_id: 1, priority: 1, item_score: 0.3 },
   1364      ]);
   1365 
   1366      assert.deepEqual(result, [
   1367        {
   1368          id: 1,
   1369          flight_id: 1,
   1370          priority: 1,
   1371          score: 0.3,
   1372          item_score: 0.3,
   1373        },
   1374        {
   1375          id: 2,
   1376          flight_id: 3,
   1377          priority: 1,
   1378          score: 0.2,
   1379          item_score: 0.2,
   1380        },
   1381        {
   1382          id: 5,
   1383          flight_id: 5,
   1384          priority: 2,
   1385          score: 0.8,
   1386          item_score: 0.8,
   1387        },
   1388        {
   1389          id: 6,
   1390          flight_id: 6,
   1391          priority: 2,
   1392          score: 0.7,
   1393          item_score: 0.7,
   1394        },
   1395        { id: 3, flight_id: 3, item_score: 0.8, score: 0.8 },
   1396        { id: 4, flight_id: 4, item_score: 0.6, score: 0.6 },
   1397      ]);
   1398    });
   1399 
   1400    it("should add a score prop to spocs", async () => {
   1401      const { data: result } = await feed.scoreItems([
   1402        { flight_id: 1, item_score: 0.9 },
   1403      ]);
   1404 
   1405      assert.equal(result[0].score, 0.9);
   1406    });
   1407  });
   1408 
   1409  describe("#filterBlocked", () => {
   1410    it("should return initial data from filterBlocked if spocs are empty", async () => {
   1411      const { data: result } = await feed.filterBlocked([]);
   1412 
   1413      assert.equal(result.length, 0);
   1414    });
   1415    it("should return initial data if links are not blocked", async () => {
   1416      const { data: result } = await feed.filterBlocked([
   1417        { url: "https://foo.com" },
   1418        { url: "test.com" },
   1419      ]);
   1420      assert.equal(result.length, 2);
   1421    });
   1422    it("should return filtered data if links are blocked", async () => {
   1423      const fakeBlocks = {
   1424        flight_id_3: 1,
   1425      };
   1426      sandbox.stub(feed, "readDataPref").returns(fakeBlocks);
   1427      sandbox
   1428        .stub(fakeNewTabUtils.blockedLinks, "isBlocked")
   1429        .callsFake(({ url }) => url === "https://blocked_url.com");
   1430      const cache = {
   1431        recsBlocks: {
   1432          id_4: 1,
   1433        },
   1434      };
   1435      sandbox.stub(feed.cache, "get").returns(Promise.resolve());
   1436      sandbox.stub(feed.cache, "set");
   1437      feed.cache.get.resolves(cache);
   1438      const { data: result } = await feed.filterBlocked([
   1439        {
   1440          url: "https://not_blocked.com",
   1441          flight_id: "flight_id_1",
   1442          id: "id_1",
   1443        },
   1444        {
   1445          url: "https://blocked_url.com",
   1446          flight_id: "flight_id_2",
   1447          id: "id_2",
   1448        },
   1449        {
   1450          url: "https://blocked_flight.com",
   1451          flight_id: "flight_id_3",
   1452          id: "id_3",
   1453        },
   1454        { url: "https://blocked_id.com", flight_id: "flight_id_4", id: "id_4" },
   1455      ]);
   1456      assert.equal(result.length, 1);
   1457      assert.equal(result[0].url, "https://not_blocked.com");
   1458    });
   1459    it("filterRecommendations based on blockedlist by passing feed data", () => {
   1460      fakeNewTabUtils.blockedLinks.links = [{ url: "https://foo.com" }];
   1461      fakeNewTabUtils.blockedLinks.isBlocked = site =>
   1462        fakeNewTabUtils.blockedLinks.links[0].url === site.url;
   1463 
   1464      const result = feed.filterRecommendations({
   1465        lastUpdated: 4,
   1466        data: {
   1467          recommendations: [{ url: "https://foo.com" }, { url: "test.com" }],
   1468        },
   1469      });
   1470 
   1471      assert.equal(result.lastUpdated, 4);
   1472      assert.lengthOf(result.data.recommendations, 1);
   1473      assert.equal(result.data.recommendations[0].url, "test.com");
   1474      assert.notInclude(
   1475        result.data.recommendations,
   1476        fakeNewTabUtils.blockedLinks.links[0]
   1477      );
   1478    });
   1479  });
   1480 
   1481  describe("#frequencyCapSpocs", () => {
   1482    it("should return filtered out spocs based on frequency caps", () => {
   1483      const fakeSpocs = [
   1484        {
   1485          id: 1,
   1486          flight_id: "seen",
   1487          caps: {
   1488            lifetime: 3,
   1489            flight: {
   1490              count: 1,
   1491              period: 1,
   1492            },
   1493          },
   1494        },
   1495        {
   1496          id: 2,
   1497          flight_id: "not-seen",
   1498          caps: {
   1499            lifetime: 3,
   1500            flight: {
   1501              count: 1,
   1502              period: 1,
   1503            },
   1504          },
   1505        },
   1506      ];
   1507      const fakeImpressions = {
   1508        seen: [Date.now() - 1],
   1509      };
   1510      sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
   1511 
   1512      const { data: result, filtered } = feed.frequencyCapSpocs(fakeSpocs);
   1513 
   1514      assert.equal(result.length, 1);
   1515      assert.equal(result[0].flight_id, "not-seen");
   1516      assert.deepEqual(filtered, [fakeSpocs[0]]);
   1517    });
   1518    it("should return simple structure and do nothing with no spocs", () => {
   1519      const { data: result, filtered } = feed.frequencyCapSpocs([]);
   1520 
   1521      assert.equal(result.length, 0);
   1522      assert.equal(filtered.length, 0);
   1523    });
   1524  });
   1525 
   1526  describe("#migrateFlightId", () => {
   1527    it("should migrate campaign to flight if no flight exists", () => {
   1528      const fakeSpocs = [
   1529        {
   1530          id: 1,
   1531          campaign_id: "campaign",
   1532          caps: {
   1533            lifetime: 3,
   1534            campaign: {
   1535              count: 1,
   1536              period: 1,
   1537            },
   1538          },
   1539        },
   1540      ];
   1541      const { data: result } = feed.migrateFlightId(fakeSpocs);
   1542 
   1543      assert.deepEqual(result[0], {
   1544        id: 1,
   1545        flight_id: "campaign",
   1546        campaign_id: "campaign",
   1547        caps: {
   1548          lifetime: 3,
   1549          flight: {
   1550            count: 1,
   1551            period: 1,
   1552          },
   1553          campaign: {
   1554            count: 1,
   1555            period: 1,
   1556          },
   1557        },
   1558      });
   1559    });
   1560    it("should not migrate campaign to flight if caps or id don't exist", () => {
   1561      const fakeSpocs = [{ id: 1 }];
   1562      const { data: result } = feed.migrateFlightId(fakeSpocs);
   1563 
   1564      assert.deepEqual(result[0], { id: 1 });
   1565    });
   1566    it("should return simple structure and do nothing with no spocs", () => {
   1567      const { data: result } = feed.migrateFlightId([]);
   1568 
   1569      assert.equal(result.length, 0);
   1570    });
   1571  });
   1572 
   1573  describe("#isBelowFrequencyCap", () => {
   1574    it("should return true if there are no flight impressions", () => {
   1575      const fakeImpressions = {
   1576        seen: [Date.now() - 1],
   1577      };
   1578      const fakeSpoc = {
   1579        flight_id: "not-seen",
   1580        caps: {
   1581          lifetime: 3,
   1582          flight: {
   1583            count: 1,
   1584            period: 1,
   1585          },
   1586        },
   1587      };
   1588 
   1589      const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc);
   1590 
   1591      assert.isTrue(result);
   1592    });
   1593    it("should return true if there are no flight caps", () => {
   1594      const fakeImpressions = {
   1595        seen: [Date.now() - 1],
   1596      };
   1597      const fakeSpoc = {
   1598        flight_id: "seen",
   1599        caps: {
   1600          lifetime: 3,
   1601        },
   1602      };
   1603 
   1604      const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc);
   1605 
   1606      assert.isTrue(result);
   1607    });
   1608 
   1609    it("should return false if lifetime cap is hit", () => {
   1610      const fakeImpressions = {
   1611        seen: [Date.now() - 1],
   1612      };
   1613      const fakeSpoc = {
   1614        flight_id: "seen",
   1615        caps: {
   1616          lifetime: 1,
   1617          flight: {
   1618            count: 3,
   1619            period: 1,
   1620          },
   1621        },
   1622      };
   1623 
   1624      const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc);
   1625 
   1626      assert.isFalse(result);
   1627    });
   1628 
   1629    it("should return false if time based cap is hit", () => {
   1630      const fakeImpressions = {
   1631        seen: [Date.now() - 1],
   1632      };
   1633      const fakeSpoc = {
   1634        flight_id: "seen",
   1635        caps: {
   1636          lifetime: 3,
   1637          flight: {
   1638            count: 1,
   1639            period: 1,
   1640          },
   1641        },
   1642      };
   1643 
   1644      const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc);
   1645 
   1646      assert.isFalse(result);
   1647    });
   1648  });
   1649 
   1650  describe("#retryFeed", () => {
   1651    it("should retry a feed fetch", async () => {
   1652      sandbox.stub(feed, "getComponentFeed").returns(Promise.resolve({}));
   1653      sandbox.spy(feed.store, "dispatch");
   1654 
   1655      await feed.retryFeed({ url: "https://feed.com" });
   1656 
   1657      assert.calledOnce(feed.getComponentFeed);
   1658      assert.calledOnce(feed.store.dispatch);
   1659      assert.equal(
   1660        feed.store.dispatch.firstCall.args[0].type,
   1661        "DISCOVERY_STREAM_FEED_UPDATE"
   1662      );
   1663      assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, {
   1664        feed: {},
   1665        url: "https://feed.com",
   1666      });
   1667    });
   1668  });
   1669 
   1670  describe("#recordFlightImpression", () => {
   1671    it("should return false if time based cap is hit", () => {
   1672      sandbox.stub(feed, "readDataPref").returns({});
   1673      sandbox.stub(feed, "writeDataPref").returns();
   1674 
   1675      feed.recordFlightImpression("seen");
   1676 
   1677      assert.calledWith(feed.writeDataPref, SPOC_IMPRESSION_TRACKING_PREF, {
   1678        seen: [0],
   1679      });
   1680    });
   1681  });
   1682 
   1683  describe("#recordBlockFlightId", () => {
   1684    it("should call writeDataPref with new flight id added", () => {
   1685      sandbox.stub(feed, "readDataPref").returns({ 1234: 1 });
   1686      sandbox.stub(feed, "writeDataPref").returns();
   1687 
   1688      feed.recordBlockFlightId("5678");
   1689 
   1690      assert.calledOnce(feed.readDataPref);
   1691      assert.calledWith(feed.writeDataPref, "discoverystream.flight.blocks", {
   1692        1234: 1,
   1693        5678: 1,
   1694      });
   1695    });
   1696  });
   1697 
   1698  describe("#cleanUpFlightImpressionPref", () => {
   1699    it("should remove flight-3 because it is no longer being used", async () => {
   1700      const fakeSpocs = {
   1701        spocs: {
   1702          items: [
   1703            {
   1704              flight_id: "flight-1",
   1705              caps: {
   1706                lifetime: 3,
   1707                flight: {
   1708                  count: 1,
   1709                  period: 1,
   1710                },
   1711              },
   1712            },
   1713            {
   1714              flight_id: "flight-2",
   1715              caps: {
   1716                lifetime: 3,
   1717                flight: {
   1718                  count: 1,
   1719                  period: 1,
   1720                },
   1721              },
   1722            },
   1723          ],
   1724        },
   1725      };
   1726      const fakeImpressions = {
   1727        "flight-2": [Date.now() - 1],
   1728        "flight-3": [Date.now() - 1],
   1729      };
   1730      sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
   1731      sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
   1732      sandbox.stub(feed, "writeDataPref").returns();
   1733 
   1734      feed.cleanUpFlightImpressionPref(fakeSpocs);
   1735 
   1736      assert.calledWith(feed.writeDataPref, SPOC_IMPRESSION_TRACKING_PREF, {
   1737        "flight-2": [-1],
   1738      });
   1739    });
   1740  });
   1741 
   1742  describe("#recordTopRecImpression", () => {
   1743    it("should add a rec id to the rec impression pref", async () => {
   1744      const cache = {
   1745        recsImpressions: {},
   1746      };
   1747      sandbox.stub(feed.cache, "get").returns(Promise.resolve());
   1748      sandbox.stub(feed.cache, "set");
   1749      feed.cache.get.resolves(cache);
   1750 
   1751      await feed.recordTopRecImpression("rec");
   1752 
   1753      assert.calledWith(feed.cache.set, "recsImpressions", {
   1754        rec: 0,
   1755      });
   1756    });
   1757    it("should not add an impression if it already exists", async () => {
   1758      const cache = {
   1759        recsImpressions: { rec: 4 },
   1760      };
   1761      sandbox.stub(feed.cache, "get").returns(Promise.resolve());
   1762      sandbox.stub(feed.cache, "set");
   1763      feed.cache.get.resolves(cache);
   1764 
   1765      await feed.recordTopRecImpression("rec");
   1766 
   1767      assert.notCalled(feed.cache.set);
   1768    });
   1769  });
   1770 
   1771  describe("#cleanUpTopRecImpressions", () => {
   1772    it("should remove rec impressions older than 7 days", async () => {
   1773      const fakeImpressions = {
   1774        rec2: Date.now(),
   1775        rec3: Date.now(),
   1776        rec5: Date.now() - 7 * 24 * 60 * 60 * 1000, // 7 days
   1777      };
   1778 
   1779      const cache = {
   1780        recsImpressions: fakeImpressions,
   1781      };
   1782      sandbox.stub(feed.cache, "get").returns(Promise.resolve());
   1783      sandbox.stub(feed.cache, "set");
   1784      feed.cache.get.resolves(cache);
   1785 
   1786      await feed.cleanUpTopRecImpressions();
   1787 
   1788      assert.calledWith(feed.cache.set, "recsImpressions", {
   1789        rec2: 0,
   1790        rec3: 0,
   1791      });
   1792    });
   1793  });
   1794 
   1795  describe("#writeDataPref", () => {
   1796    it("should call Services.prefs.setStringPref", () => {
   1797      sandbox.spy(feed.store, "dispatch");
   1798      const fakeImpressions = {
   1799        foo: [Date.now() - 1],
   1800        bar: [Date.now() - 1],
   1801      };
   1802 
   1803      feed.writeDataPref(SPOC_IMPRESSION_TRACKING_PREF, fakeImpressions);
   1804 
   1805      assert.calledWithMatch(feed.store.dispatch, {
   1806        data: {
   1807          name: SPOC_IMPRESSION_TRACKING_PREF,
   1808          value: JSON.stringify(fakeImpressions),
   1809        },
   1810        type: at.SET_PREF,
   1811      });
   1812    });
   1813  });
   1814 
   1815  describe("#addEndpointQuery", () => {
   1816    const url = "https://spocs.getpocket.com/spocs";
   1817 
   1818    it("should return same url with no query", () => {
   1819      const result = feed.addEndpointQuery(url, "");
   1820      assert.equal(result, url);
   1821    });
   1822 
   1823    it("should add multiple query params to standard url", () => {
   1824      const params = "?first=first&second=second";
   1825      const result = feed.addEndpointQuery(url, params);
   1826      assert.equal(result, url + params);
   1827    });
   1828 
   1829    it("should add multiple query params to url with a query already", () => {
   1830      const params = "first=first&second=second";
   1831      const initialParams = "?zero=zero";
   1832      const result = feed.addEndpointQuery(
   1833        `${url}${initialParams}`,
   1834        `?${params}`
   1835      );
   1836      assert.equal(result, `${url}${initialParams}&${params}`);
   1837    });
   1838  });
   1839 
   1840  describe("#readDataPref", () => {
   1841    it("should return what's in Services.prefs.getStringPref", () => {
   1842      const fakeImpressions = {
   1843        foo: [Date.now() - 1],
   1844        bar: [Date.now() - 1],
   1845      };
   1846      setPref(SPOC_IMPRESSION_TRACKING_PREF, fakeImpressions);
   1847 
   1848      const result = feed.readDataPref(SPOC_IMPRESSION_TRACKING_PREF);
   1849 
   1850      assert.deepEqual(result, fakeImpressions);
   1851    });
   1852  });
   1853 
   1854  describe("#setupPrefs", () => {
   1855    it("should call setupPrefs", async () => {
   1856      sandbox.spy(feed, "setupPrefs");
   1857      feed.onAction({
   1858        type: at.INIT,
   1859      });
   1860      assert.calledOnce(feed.setupPrefs);
   1861    });
   1862    it("should dispatch to at.DISCOVERY_STREAM_PREFS_SETUP with proper data", async () => {
   1863      sandbox.spy(feed.store, "dispatch");
   1864      sandbox
   1865        .stub(global.NimbusFeatures.pocketNewtab, "getEnrollmentMetadata")
   1866        .returns({
   1867          slug: "experimentId",
   1868          branch: "branchId",
   1869          isRollout: false,
   1870        });
   1871      feed.store.getState = () => ({
   1872        Prefs: {
   1873          values: {
   1874            region: "CA",
   1875            pocketConfig: {
   1876              hideDescriptions: false,
   1877              hideDescriptionsRegions: "US,CA,GB",
   1878              compactImages: true,
   1879              imageGradient: true,
   1880              newSponsoredLabel: true,
   1881              titleLines: "1",
   1882              descLines: "1",
   1883              readTime: true,
   1884            },
   1885          },
   1886        },
   1887      });
   1888      feed.setupPrefs();
   1889      assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, {
   1890        utmSource: "pocket-newtab",
   1891        utmCampaign: "experimentId",
   1892        utmContent: "branchId",
   1893      });
   1894      assert.deepEqual(feed.store.dispatch.secondCall.args[0].data, {
   1895        hideDescriptions: true,
   1896        compactImages: true,
   1897        imageGradient: true,
   1898        newSponsoredLabel: true,
   1899        titleLines: "1",
   1900        descLines: "1",
   1901        readTime: true,
   1902      });
   1903    });
   1904  });
   1905 
   1906  describe("#onAction: DISCOVERY_STREAM_IMPRESSION_STATS", () => {
   1907    it("should call recordTopRecImpressions from DISCOVERY_STREAM_IMPRESSION_STATS", async () => {
   1908      sandbox.stub(feed, "recordTopRecImpression").returns();
   1909      await feed.onAction({
   1910        type: at.DISCOVERY_STREAM_IMPRESSION_STATS,
   1911        data: { tiles: [{ id: "seen" }] },
   1912      });
   1913 
   1914      assert.calledWith(feed.recordTopRecImpression, "seen");
   1915    });
   1916  });
   1917 
   1918  describe("#onAction: DISCOVERY_STREAM_SPOC_IMPRESSION", () => {
   1919    beforeEach(() => {
   1920      const data = {
   1921        spocs: {
   1922          items: [
   1923            {
   1924              id: 1,
   1925              flight_id: "seen",
   1926              caps: {
   1927                lifetime: 3,
   1928                flight: {
   1929                  count: 1,
   1930                  period: 1,
   1931                },
   1932              },
   1933            },
   1934            {
   1935              id: 2,
   1936              flight_id: "not-seen",
   1937              caps: {
   1938                lifetime: 3,
   1939                flight: {
   1940                  count: 1,
   1941                  period: 1,
   1942                },
   1943              },
   1944            },
   1945          ],
   1946        },
   1947      };
   1948      sandbox.stub(feed.store, "getState").returns({
   1949        DiscoveryStream: {
   1950          spocs: {
   1951            data,
   1952          },
   1953        },
   1954        Prefs: {
   1955          values: {
   1956            trainhopConfig: {},
   1957          },
   1958        },
   1959      });
   1960    });
   1961 
   1962    it("should call dispatch to ac.AlsoToPreloaded with filtered spoc data", async () => {
   1963      sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
   1964      Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
   1965      const fakeImpressions = {
   1966        seen: [Date.now() - 1],
   1967      };
   1968      const result = {
   1969        spocs: {
   1970          items: [
   1971            {
   1972              id: 2,
   1973              flight_id: "not-seen",
   1974              caps: {
   1975                lifetime: 3,
   1976                flight: {
   1977                  count: 1,
   1978                  period: 1,
   1979                },
   1980              },
   1981            },
   1982          ],
   1983        },
   1984      };
   1985      sandbox.stub(feed, "recordFlightImpression").returns();
   1986      sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
   1987      sandbox.spy(feed.store, "dispatch");
   1988 
   1989      await feed.onAction({
   1990        type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
   1991        data: { flightId: "seen" },
   1992      });
   1993 
   1994      assert.deepEqual(
   1995        feed.store.dispatch.secondCall.args[0].data.spocs,
   1996        result
   1997      );
   1998    });
   1999    it("should not call dispatch to ac.AlsoToPreloaded if spocs were not changed by frequency capping", async () => {
   2000      sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
   2001      Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
   2002      const fakeImpressions = {};
   2003      sandbox.stub(feed, "recordFlightImpression").returns();
   2004      sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
   2005      sandbox.spy(feed.store, "dispatch");
   2006 
   2007      await feed.onAction({
   2008        type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
   2009        data: { flight_id: "seen" },
   2010      });
   2011 
   2012      assert.notCalled(feed.store.dispatch);
   2013    });
   2014    it("should attempt feq cap on valid spocs with placements on impression", async () => {
   2015      sandbox.restore();
   2016      Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
   2017      const fakeImpressions = {};
   2018      sandbox.stub(feed, "recordFlightImpression").returns();
   2019      sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
   2020      sandbox.spy(feed.store, "dispatch");
   2021      sandbox.spy(feed, "frequencyCapSpocs");
   2022 
   2023      const data = {
   2024        spocs: {
   2025          items: [
   2026            {
   2027              id: 2,
   2028              flight_id: "seen-2",
   2029              caps: {
   2030                lifetime: 3,
   2031                flight: {
   2032                  count: 1,
   2033                  period: 1,
   2034                },
   2035              },
   2036            },
   2037          ],
   2038        },
   2039      };
   2040      sandbox.stub(feed.store, "getState").returns({
   2041        DiscoveryStream: {
   2042          spocs: {
   2043            data,
   2044            placements: [{ name: "spocs" }, { name: "notSpocs" }],
   2045          },
   2046        },
   2047      });
   2048 
   2049      await feed.onAction({
   2050        type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
   2051        data: { flight_id: "doesn't matter" },
   2052      });
   2053 
   2054      assert.calledOnce(feed.frequencyCapSpocs);
   2055      assert.calledWith(feed.frequencyCapSpocs, data.spocs.items);
   2056    });
   2057  });
   2058 
   2059  describe("#onAction: PLACES_LINK_BLOCKED", () => {
   2060    beforeEach(() => {
   2061      const spocsData = {
   2062        data: {
   2063          spocs: {
   2064            items: [
   2065              {
   2066                id: 1,
   2067                flight_id: "foo",
   2068                url: "foo.com",
   2069              },
   2070              {
   2071                id: 2,
   2072                flight_id: "bar",
   2073                url: "bar.com",
   2074              },
   2075            ],
   2076          },
   2077        },
   2078        placements: [{ name: "spocs" }],
   2079      };
   2080      const feedsData = {
   2081        data: {},
   2082      };
   2083      sandbox.stub(feed.store, "getState").returns({
   2084        DiscoveryStream: {
   2085          spocs: spocsData,
   2086          feeds: feedsData,
   2087        },
   2088      });
   2089    });
   2090    it("should call dispatch if found a blocked spoc", async () => {
   2091      Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
   2092      Object.defineProperty(feed, "spocsOnDemand", { get: () => false });
   2093      Object.defineProperty(feed, "spocsCacheUpdateTime", {
   2094        get: () => 30 * 60 * 1000,
   2095      });
   2096 
   2097      sandbox.spy(feed.store, "dispatch");
   2098 
   2099      await feed.onAction({
   2100        type: at.PLACES_LINK_BLOCKED,
   2101        data: { url: "foo.com" },
   2102      });
   2103 
   2104      assert.deepEqual(
   2105        feed.store.dispatch.firstCall.args[0].data.url,
   2106        "foo.com"
   2107      );
   2108    });
   2109    it("should dispatch once if the blocked is not a SPOC", async () => {
   2110      Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
   2111      sandbox.spy(feed.store, "dispatch");
   2112 
   2113      await feed.onAction({
   2114        type: at.PLACES_LINK_BLOCKED,
   2115        data: { url: "not_a_spoc.com" },
   2116      });
   2117 
   2118      assert.calledOnce(feed.store.dispatch);
   2119      assert.deepEqual(
   2120        feed.store.dispatch.firstCall.args[0].data.url,
   2121        "not_a_spoc.com"
   2122      );
   2123    });
   2124    it("should dispatch a DISCOVERY_STREAM_SPOC_BLOCKED for a blocked spoc", async () => {
   2125      Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
   2126      Object.defineProperty(feed, "spocsOnDemand", { get: () => false });
   2127      Object.defineProperty(feed, "spocsCacheUpdateTime", {
   2128        get: () => 30 * 60 * 1000,
   2129      });
   2130      sandbox.spy(feed.store, "dispatch");
   2131 
   2132      await feed.onAction({
   2133        type: at.PLACES_LINK_BLOCKED,
   2134        data: { url: "foo.com" },
   2135      });
   2136 
   2137      assert.equal(
   2138        feed.store.dispatch.secondCall.args[0].type,
   2139        "DISCOVERY_STREAM_SPOC_BLOCKED"
   2140      );
   2141    });
   2142  });
   2143 
   2144  describe("#onAction: BLOCK_URL", () => {
   2145    it("should call recordBlockFlightId whith BLOCK_URL", async () => {
   2146      sandbox.stub(feed, "recordBlockFlightId").returns();
   2147 
   2148      await feed.onAction({
   2149        type: at.BLOCK_URL,
   2150        data: [
   2151          {
   2152            flight_id: "1234",
   2153          },
   2154        ],
   2155      });
   2156 
   2157      assert.calledWith(feed.recordBlockFlightId, "1234");
   2158    });
   2159  });
   2160 
   2161  describe("#onAction: INIT", () => {
   2162    it("should be .loaded=false before initialization", () => {
   2163      assert.isFalse(feed.loaded);
   2164    });
   2165    it("should load data and set .loaded=true if config.enabled is true", async () => {
   2166      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
   2167      setPref(CONFIG_PREF_NAME, { enabled: true });
   2168      sandbox.stub(feed, "loadLayout").returns(Promise.resolve());
   2169 
   2170      await feed.onAction({ type: at.INIT });
   2171 
   2172      assert.calledOnce(feed.loadLayout);
   2173      assert.isTrue(feed.loaded);
   2174    });
   2175  });
   2176 
   2177  describe("#onAction: DISCOVERY_STREAM_CONFIG_SET_VALUE", async () => {
   2178    it("should add the new value to the pref without changing the existing values", async () => {
   2179      sandbox.spy(feed.store, "dispatch");
   2180      setPref(CONFIG_PREF_NAME, { enabled: true, other: "value" });
   2181 
   2182      await feed.onAction({
   2183        type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE,
   2184        data: { name: "api_key_pref", value: "foo" },
   2185      });
   2186 
   2187      assert.calledWithMatch(feed.store.dispatch, {
   2188        data: {
   2189          name: CONFIG_PREF_NAME,
   2190          value: JSON.stringify({
   2191            enabled: true,
   2192            other: "value",
   2193            api_key_pref: "foo",
   2194          }),
   2195        },
   2196        type: at.SET_PREF,
   2197      });
   2198    });
   2199  });
   2200 
   2201  describe("#onAction: DISCOVERY_STREAM_CONFIG_RESET", async () => {
   2202    it("should call configReset", async () => {
   2203      sandbox.spy(feed, "configReset");
   2204      feed.onAction({
   2205        type: at.DISCOVERY_STREAM_CONFIG_RESET,
   2206      });
   2207      assert.calledOnce(feed.configReset);
   2208    });
   2209  });
   2210 
   2211  describe("#onAction: DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", async () => {
   2212    it("Should dispatch CLEAR_PREF with pref name", async () => {
   2213      sandbox.spy(feed.store, "dispatch");
   2214      await feed.onAction({
   2215        type: at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS,
   2216      });
   2217 
   2218      assert.calledWithMatch(feed.store.dispatch, {
   2219        data: {
   2220          name: CONFIG_PREF_NAME,
   2221        },
   2222        type: at.CLEAR_PREF,
   2223      });
   2224    });
   2225  });
   2226 
   2227  describe("#onAction: DISCOVERY_STREAM_RETRY_FEED", async () => {
   2228    it("should call retryFeed", async () => {
   2229      sandbox.spy(feed, "retryFeed");
   2230      feed.onAction({
   2231        type: at.DISCOVERY_STREAM_RETRY_FEED,
   2232        data: { feed: { url: "https://feed.com" } },
   2233      });
   2234      assert.calledOnce(feed.retryFeed);
   2235      assert.calledWith(feed.retryFeed, { url: "https://feed.com" });
   2236    });
   2237  });
   2238 
   2239  describe("#onAction: DISCOVERY_STREAM_CONFIG_CHANGE", () => {
   2240    it("should call this.loadLayout if config.enabled changes to true ", async () => {
   2241      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
   2242      // First initialize
   2243      await feed.onAction({ type: at.INIT });
   2244      assert.isFalse(feed.loaded);
   2245 
   2246      // force clear cached pref value
   2247      feed._prefCache = {};
   2248      setPref(CONFIG_PREF_NAME, { enabled: true });
   2249 
   2250      sandbox.stub(feed, "resetCache").returns(Promise.resolve());
   2251      sandbox.stub(feed, "loadLayout").returns(Promise.resolve());
   2252      await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
   2253 
   2254      assert.calledOnce(feed.loadLayout);
   2255      assert.calledOnce(feed.resetCache);
   2256      assert.isTrue(feed.loaded);
   2257    });
   2258    it("should clear the cache if a config change happens and config.enabled is true", async () => {
   2259      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
   2260      // force clear cached pref value
   2261      feed._prefCache = {};
   2262      setPref(CONFIG_PREF_NAME, { enabled: true });
   2263 
   2264      sandbox.stub(feed, "resetCache").returns(Promise.resolve());
   2265      await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
   2266 
   2267      assert.calledOnce(feed.resetCache);
   2268    });
   2269    it("should dispatch DISCOVERY_STREAM_LAYOUT_RESET from DISCOVERY_STREAM_CONFIG_CHANGE", async () => {
   2270      sandbox.stub(feed, "resetDataPrefs");
   2271      sandbox.stub(feed, "resetCache").resolves();
   2272      sandbox.stub(feed, "enable").resolves();
   2273      setPref(CONFIG_PREF_NAME, { enabled: true });
   2274      sandbox.spy(feed.store, "dispatch");
   2275 
   2276      await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
   2277 
   2278      assert.calledWithMatch(feed.store.dispatch, {
   2279        type: at.DISCOVERY_STREAM_LAYOUT_RESET,
   2280      });
   2281    });
   2282    it("should not call this.loadLayout if config.enabled changes to false", async () => {
   2283      sandbox.stub(feed.cache, "set").returns(Promise.resolve());
   2284      // force clear cached pref value
   2285      feed._prefCache = {};
   2286      setPref(CONFIG_PREF_NAME, { enabled: true });
   2287 
   2288      await feed.onAction({ type: at.INIT });
   2289      assert.isTrue(feed.loaded);
   2290 
   2291      feed._prefCache = {};
   2292      setPref(CONFIG_PREF_NAME, { enabled: false });
   2293      sandbox.stub(feed, "resetCache").returns(Promise.resolve());
   2294      sandbox.stub(feed, "loadLayout").returns(Promise.resolve());
   2295      await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
   2296 
   2297      assert.notCalled(feed.loadLayout);
   2298      assert.calledOnce(feed.resetCache);
   2299      assert.isFalse(feed.loaded);
   2300    });
   2301  });
   2302 
   2303  describe("#onAction: UNINIT", () => {
   2304    it("should reset pref cache", async () => {
   2305      feed._prefCache = { cached: "value" };
   2306 
   2307      await feed.onAction({ type: at.UNINIT });
   2308 
   2309      assert.deepEqual(feed._prefCache, {});
   2310    });
   2311  });
   2312 
   2313  describe("#onAction: PREF_CHANGED", () => {
   2314    it("should update state.DiscoveryStream.config when the pref changes", async () => {
   2315      setPref(CONFIG_PREF_NAME, {
   2316        enabled: true,
   2317        api_key_pref: "foo",
   2318      });
   2319 
   2320      assert.deepEqual(feed.store.getState().DiscoveryStream.config, {
   2321        enabled: true,
   2322        api_key_pref: "foo",
   2323      });
   2324    });
   2325    it("should fire loadSpocs is showSponsored pref changes", async () => {
   2326      sandbox.stub(feed, "loadSpocs").returns(Promise.resolve());
   2327 
   2328      await feed.onAction({
   2329        type: at.PREF_CHANGED,
   2330        data: { name: "showSponsored" },
   2331      });
   2332 
   2333      assert.calledOnce(feed.loadSpocs);
   2334    });
   2335    it("should fire onPrefChange when pocketConfig pref changes", async () => {
   2336      sandbox.stub(feed, "onPrefChange").returns(Promise.resolve());
   2337 
   2338      await feed.onAction({
   2339        type: at.PREF_CHANGED,
   2340        data: { name: "pocketConfig", value: false },
   2341      });
   2342 
   2343      assert.calledOnce(feed.onPrefChange);
   2344    });
   2345    it("should re enable stories when top stories is turned on", async () => {
   2346      sandbox.stub(feed, "refreshAll").returns(Promise.resolve());
   2347      feed.loaded = true;
   2348      setPref(CONFIG_PREF_NAME, {
   2349        enabled: true,
   2350      });
   2351 
   2352      await feed.onAction({
   2353        type: at.PREF_CHANGED,
   2354        data: { name: "feeds.section.topstories", value: true },
   2355      });
   2356 
   2357      assert.calledOnce(feed.refreshAll);
   2358    });
   2359    it("shoud update allowlist", async () => {
   2360      assert.equal(
   2361        feed.store.getState().Prefs.values[ENDPOINTS_PREF_NAME],
   2362        DUMMY_ENDPOINT
   2363      );
   2364      setPref(ENDPOINTS_PREF_NAME, "sick-kickflip.mozilla.net");
   2365      assert.equal(
   2366        feed.store.getState().Prefs.values[ENDPOINTS_PREF_NAME],
   2367        "sick-kickflip.mozilla.net"
   2368      );
   2369    });
   2370  });
   2371 
   2372  describe("#onAction: SYSTEM_TICK", () => {
   2373    it("should not refresh if DiscoveryStream has not been loaded", async () => {
   2374      sandbox.stub(feed, "refreshAll").resolves();
   2375      setPref(CONFIG_PREF_NAME, { enabled: true });
   2376 
   2377      await feed.onAction({ type: at.SYSTEM_TICK });
   2378      assert.notCalled(feed.refreshAll);
   2379    });
   2380 
   2381    it("should not refresh if no caches are expired", async () => {
   2382      sandbox.stub(feed.cache, "set").resolves();
   2383      setPref(CONFIG_PREF_NAME, { enabled: true });
   2384 
   2385      await feed.onAction({ type: at.INIT });
   2386 
   2387      sandbox.stub(feed, "onSystemTick").resolves();
   2388      sandbox.stub(feed, "refreshAll").resolves();
   2389 
   2390      await feed.onAction({ type: at.SYSTEM_TICK });
   2391      assert.notCalled(feed.refreshAll);
   2392    });
   2393 
   2394    it("should refresh if DiscoveryStream has been loaded at least once and a cache has expired", async () => {
   2395      sandbox.stub(feed.cache, "set").resolves();
   2396      setPref(CONFIG_PREF_NAME, { enabled: true });
   2397 
   2398      await feed.onAction({ type: at.INIT });
   2399 
   2400      sandbox.stub(feed, "refreshAll").resolves();
   2401 
   2402      await feed.onAction({ type: at.SYSTEM_TICK });
   2403      assert.calledOnce(feed.refreshAll);
   2404    });
   2405 
   2406    it("should refresh and not update open tabs if DiscoveryStream has been loaded at least once", async () => {
   2407      sandbox.stub(feed.cache, "set").resolves();
   2408      setPref(CONFIG_PREF_NAME, { enabled: true });
   2409 
   2410      await feed.onAction({ type: at.INIT });
   2411 
   2412      sandbox.stub(feed, "refreshAll").resolves();
   2413 
   2414      await feed.onAction({ type: at.SYSTEM_TICK });
   2415      assert.calledWith(feed.refreshAll, {
   2416        updateOpenTabs: false,
   2417        isSystemTick: true,
   2418      });
   2419    });
   2420  });
   2421 
   2422  describe("#enable", () => {
   2423    it("should pass along proper options to refreshAll from enable", async () => {
   2424      sandbox.stub(feed, "refreshAll");
   2425      await feed.enable();
   2426      assert.calledWith(feed.refreshAll, {});
   2427      await feed.enable({ updateOpenTabs: true });
   2428      assert.calledWith(feed.refreshAll, { updateOpenTabs: true });
   2429      await feed.enable({ isStartup: true });
   2430      assert.calledWith(feed.refreshAll, { isStartup: true });
   2431      await feed.enable({ updateOpenTabs: true, isStartup: true });
   2432      assert.calledWith(feed.refreshAll, {
   2433        updateOpenTabs: true,
   2434        isStartup: true,
   2435      });
   2436    });
   2437  });
   2438 
   2439  describe("#onPrefChange", () => {
   2440    it("should call loadLayout when Pocket config changes", async () => {
   2441      sandbox.stub(feed, "loadLayout");
   2442      feed._prefCache.config = {
   2443        enabled: true,
   2444      };
   2445      await feed.onPrefChange();
   2446      assert.calledOnce(feed.loadLayout);
   2447    });
   2448    it("should update open tabs but not startup with onPrefChange", async () => {
   2449      sandbox.stub(feed, "refreshAll");
   2450      feed._prefCache.config = {
   2451        enabled: true,
   2452      };
   2453      await feed.onPrefChange();
   2454      assert.calledWith(feed.refreshAll, { updateOpenTabs: true });
   2455    });
   2456  });
   2457 
   2458  describe("#onAction: PREF_SHOW_SPONSORED", () => {
   2459    it("should call loadSpocs when preference changes", async () => {
   2460      sandbox.stub(feed, "loadSpocs").resolves();
   2461      sandbox.stub(feed.store, "dispatch");
   2462 
   2463      await feed.onAction({
   2464        type: at.PREF_CHANGED,
   2465        data: { name: "showSponsored" },
   2466      });
   2467 
   2468      assert.calledOnce(feed.loadSpocs);
   2469      const [dispatchFn] = feed.loadSpocs.firstCall.args;
   2470      dispatchFn({});
   2471      assert.calledWith(feed.store.dispatch, ac.BroadcastToContent({}));
   2472    });
   2473  });
   2474 
   2475  describe("#onAction: DISCOVERY_STREAM_DEV_SYNC_RS", () => {
   2476    it("should fire remote settings pollChanges", async () => {
   2477      sandbox.stub(global.RemoteSettings, "pollChanges").returns();
   2478      await feed.onAction({
   2479        type: at.DISCOVERY_STREAM_DEV_SYNC_RS,
   2480      });
   2481      assert.calledOnce(global.RemoteSettings.pollChanges);
   2482    });
   2483  });
   2484 
   2485  describe("#onAction: DISCOVERY_STREAM_DEV_SYSTEM_TICK", () => {
   2486    it("should refresh if DiscoveryStream has been loaded at least once and a cache has expired", async () => {
   2487      sandbox.stub(feed.cache, "set").resolves();
   2488      setPref(CONFIG_PREF_NAME, { enabled: true });
   2489 
   2490      await feed.onAction({ type: at.INIT });
   2491 
   2492      sandbox.stub(feed, "refreshAll").resolves();
   2493 
   2494      await feed.onAction({ type: at.DISCOVERY_STREAM_DEV_SYSTEM_TICK });
   2495      assert.calledOnce(feed.refreshAll);
   2496    });
   2497  });
   2498 
   2499  describe("#onAction: DISCOVERY_STREAM_DEV_EXPIRE_CACHE", () => {
   2500    it("should fire resetCache", async () => {
   2501      sandbox.stub(feed, "resetContentCache").returns();
   2502      await feed.onAction({
   2503        type: at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE,
   2504      });
   2505      assert.calledOnce(feed.resetContentCache);
   2506    });
   2507  });
   2508 
   2509  describe("#spocsCacheUpdateTime", () => {
   2510    it("should return default cache time", () => {
   2511      const defaultCacheTime = 30 * 60 * 1000;
   2512      const cacheTime = feed.spocsCacheUpdateTime;
   2513      assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
   2514      assert.equal(cacheTime, defaultCacheTime);
   2515    });
   2516    it("should return _spocsCacheUpdateTime", () => {
   2517      const testCacheTime = 123;
   2518      feed._spocsCacheUpdateTime = testCacheTime;
   2519      const cacheTime = feed.spocsCacheUpdateTime;
   2520      assert.equal(feed._spocsCacheUpdateTime, testCacheTime);
   2521      assert.equal(cacheTime, testCacheTime);
   2522    });
   2523    it("should set _spocsCacheUpdateTime with min", () => {
   2524      const defaultCacheTime = 30 * 60 * 1000;
   2525      feed.store.getState = () => ({
   2526        Prefs: {
   2527          values: {
   2528            "discoverystream.spocs.cacheTimeout": 1,
   2529            showSponsored: true,
   2530            "system.showSponsored": true,
   2531          },
   2532        },
   2533      });
   2534      const cacheTime = feed.spocsCacheUpdateTime;
   2535      assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
   2536      assert.equal(cacheTime, defaultCacheTime);
   2537    });
   2538    it("should set _spocsCacheUpdateTime with max", () => {
   2539      const defaultCacheTime = 30 * 60 * 1000;
   2540      feed.store.getState = () => ({
   2541        Prefs: {
   2542          values: {
   2543            "discoverystream.spocs.cacheTimeout": 31,
   2544            showSponsored: true,
   2545            "system.showSponsored": true,
   2546          },
   2547        },
   2548      });
   2549      const cacheTime = feed.spocsCacheUpdateTime;
   2550      assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
   2551      assert.equal(cacheTime, defaultCacheTime);
   2552    });
   2553    it("should set _spocsCacheUpdateTime with spocsCacheTimeout", () => {
   2554      const defaultCacheTime = 20 * 60 * 1000;
   2555      feed.store.getState = () => ({
   2556        Prefs: {
   2557          values: {
   2558            "discoverystream.spocs.cacheTimeout": 20,
   2559            showSponsored: true,
   2560            "system.showSponsored": true,
   2561          },
   2562        },
   2563      });
   2564      const cacheTime = feed.spocsCacheUpdateTime;
   2565      assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
   2566      assert.equal(cacheTime, defaultCacheTime);
   2567    });
   2568    it("should set _spocsCacheUpdateTime with spocsCacheTimeout and onDemand", () => {
   2569      const defaultCacheTime = 4 * 60 * 1000;
   2570      feed.store.getState = () => ({
   2571        Prefs: {
   2572          values: {
   2573            "discoverystream.spocs.onDemand": true,
   2574            "discoverystream.spocs.cacheTimeout": 4,
   2575            showSponsored: true,
   2576            "system.showSponsored": true,
   2577          },
   2578        },
   2579      });
   2580      const cacheTime = feed.spocsCacheUpdateTime;
   2581      assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
   2582      assert.equal(cacheTime, defaultCacheTime);
   2583    });
   2584    it("should set _spocsCacheUpdateTime with spocsCacheTimeout without max", () => {
   2585      const defaultCacheTime = 31 * 60 * 1000;
   2586      feed.store.getState = () => ({
   2587        Prefs: {
   2588          values: {
   2589            "discoverystream.spocs.onDemand": true,
   2590            "discoverystream.spocs.cacheTimeout": 31,
   2591            showSponsored: true,
   2592            "system.showSponsored": true,
   2593          },
   2594        },
   2595      });
   2596      const cacheTime = feed.spocsCacheUpdateTime;
   2597      assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
   2598      assert.equal(cacheTime, defaultCacheTime);
   2599    });
   2600    it("should set _spocsCacheUpdateTime with spocsCacheTimeout without min", () => {
   2601      const defaultCacheTime = 1 * 60 * 1000;
   2602      feed.store.getState = () => ({
   2603        Prefs: {
   2604          values: {
   2605            "discoverystream.spocs.onDemand": true,
   2606            "discoverystream.spocs.cacheTimeout": 1,
   2607            showSponsored: true,
   2608            "system.showSponsored": true,
   2609          },
   2610        },
   2611      });
   2612      const cacheTime = feed.spocsCacheUpdateTime;
   2613      assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
   2614      assert.equal(cacheTime, defaultCacheTime);
   2615    });
   2616  });
   2617 
   2618  describe("#isExpired", () => {
   2619    it("should throw if the key is not valid", () => {
   2620      assert.throws(() => {
   2621        feed.isExpired({}, "foo");
   2622      });
   2623    });
   2624    it("should return false for spocs on startup for content under 1 week", () => {
   2625      const spocs = { lastUpdated: Date.now() };
   2626      const result = feed.isExpired({
   2627        cachedData: { spocs },
   2628        key: "spocs",
   2629        isStartup: true,
   2630      });
   2631 
   2632      assert.isFalse(result);
   2633    });
   2634    it("should return true for spocs for isStartup=false after 30 mins", () => {
   2635      const spocs = { lastUpdated: Date.now() };
   2636      clock.tick(THIRTY_MINUTES + 1);
   2637      const result = feed.isExpired({ cachedData: { spocs }, key: "spocs" });
   2638 
   2639      assert.isTrue(result);
   2640    });
   2641    it("should return true for spocs on startup for content over 1 week", () => {
   2642      const spocs = { lastUpdated: Date.now() };
   2643      clock.tick(ONE_WEEK + 1);
   2644      const result = feed.isExpired({
   2645        cachedData: { spocs },
   2646        key: "spocs",
   2647        isStartup: true,
   2648      });
   2649 
   2650      assert.isTrue(result);
   2651    });
   2652  });
   2653 
   2654  describe("#_checkExpirationPerComponent", () => {
   2655    let cache;
   2656    beforeEach(() => {
   2657      cache = {
   2658        feeds: { "foo.com": { lastUpdated: Date.now() } },
   2659        spocs: { lastUpdated: Date.now() },
   2660      };
   2661      Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
   2662      sandbox.stub(feed.cache, "get").resolves(cache);
   2663    });
   2664 
   2665    it("should return false if nothing in the cache is expired", async () => {
   2666      const results = await feed._checkExpirationPerComponent();
   2667      assert.isFalse(results.spocs);
   2668      assert.isFalse(results.feeds);
   2669    });
   2670    it("should return true if .spocs is missing", async () => {
   2671      delete cache.spocs;
   2672 
   2673      const results = await feed._checkExpirationPerComponent();
   2674      assert.isTrue(results.spocs);
   2675      assert.isFalse(results.feeds);
   2676    });
   2677    it("should return true if .feeds is missing", async () => {
   2678      delete cache.feeds;
   2679 
   2680      const results = await feed._checkExpirationPerComponent();
   2681      assert.isFalse(results.spocs);
   2682      assert.isTrue(results.feeds);
   2683    });
   2684    it("should return true if spocs are expired", async () => {
   2685      clock.tick(THIRTY_MINUTES + 1);
   2686      // Update other caches we aren't testing
   2687      cache.feeds["foo.com"].lastUpdated = Date.now();
   2688 
   2689      const results = await feed._checkExpirationPerComponent();
   2690      assert.isTrue(results.spocs);
   2691      assert.isFalse(results.feeds);
   2692    });
   2693    it("should return true if data for .feeds[url] is missing", async () => {
   2694      cache.feeds["foo.com"] = null;
   2695 
   2696      const results = await feed._checkExpirationPerComponent();
   2697      assert.isFalse(results.spocs);
   2698      assert.isTrue(results.feeds);
   2699    });
   2700    it("should return true if data for .feeds[url] is expired", async () => {
   2701      clock.tick(THIRTY_MINUTES + 1);
   2702      // Update other caches we aren't testing
   2703      cache.spocs.lastUpdated = Date.now();
   2704 
   2705      const results = await feed._checkExpirationPerComponent();
   2706      assert.isFalse(results.spocs);
   2707      assert.isTrue(results.feeds);
   2708    });
   2709  });
   2710 
   2711  describe("#refreshAll", () => {
   2712    beforeEach(() => {
   2713      sandbox.stub(feed, "loadLayout").resolves();
   2714      sandbox.stub(feed, "loadComponentFeeds").resolves();
   2715      sandbox.stub(feed, "loadSpocs").resolves();
   2716      sandbox.spy(feed.store, "dispatch");
   2717      Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
   2718    });
   2719 
   2720    it("should call layout, component, spocs update and telemetry reporting functions", async () => {
   2721      await feed.refreshAll();
   2722 
   2723      assert.calledOnce(feed.loadLayout);
   2724      assert.calledOnce(feed.loadComponentFeeds);
   2725      assert.calledOnce(feed.loadSpocs);
   2726    });
   2727    it("should pass in dispatch wrapped with broadcast if options.updateOpenTabs is true", async () => {
   2728      await feed.refreshAll({ updateOpenTabs: true });
   2729      [feed.loadLayout, feed.loadComponentFeeds, feed.loadSpocs].forEach(fn => {
   2730        assert.calledOnce(fn);
   2731        const result = fn.firstCall.args[0]({ type: "FOO" });
   2732        assert.isTrue(au.isBroadcastToContent(result));
   2733      });
   2734    });
   2735    it("should pass in dispatch with regular actions if options.updateOpenTabs is false", async () => {
   2736      await feed.refreshAll({ updateOpenTabs: false });
   2737      [feed.loadLayout, feed.loadComponentFeeds, feed.loadSpocs].forEach(fn => {
   2738        assert.calledOnce(fn);
   2739        const result = fn.firstCall.args[0]({ type: "FOO" });
   2740        assert.deepEqual(result, { type: "FOO" });
   2741      });
   2742    });
   2743    it("should set loaded to true if loadSpocs and loadComponentFeeds fails", async () => {
   2744      feed.loadComponentFeeds.rejects("loadComponentFeeds error");
   2745      feed.loadSpocs.rejects("loadSpocs error");
   2746 
   2747      await feed.enable();
   2748 
   2749      assert.isTrue(feed.loaded);
   2750    });
   2751    it("should call loadComponentFeeds and loadSpocs in Promise.all", async () => {
   2752      sandbox.stub(global.Promise, "all").resolves();
   2753 
   2754      await feed.refreshAll();
   2755 
   2756      assert.calledOnce(global.Promise.all);
   2757      const { args } = global.Promise.all.firstCall;
   2758      assert.equal(args[0].length, 2);
   2759    });
   2760    describe("test startup cache behaviour", () => {
   2761      beforeEach(() => {
   2762        feed._maybeUpdateCachedData.restore();
   2763        sandbox.stub(feed.cache, "set").resolves();
   2764      });
   2765      it("should not refresh layout on startup if it is under THIRTY_MINUTES", async () => {
   2766        feed.loadLayout.restore();
   2767        sandbox.stub(feed.cache, "get").resolves({
   2768          layout: { lastUpdated: Date.now(), layout: {} },
   2769        });
   2770        sandbox.stub(feed, "fetchFromEndpoint").resolves({ layout: {} });
   2771 
   2772        await feed.refreshAll({ isStartup: true });
   2773 
   2774        assert.notCalled(feed.fetchFromEndpoint);
   2775      });
   2776      it("should refresh spocs on startup if it was served from cache", async () => {
   2777        feed.loadSpocs.restore();
   2778        sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
   2779        sandbox.stub(feed.cache, "get").resolves({
   2780          spocs: { lastUpdated: Date.now() },
   2781        });
   2782        clock.tick(THIRTY_MINUTES + 1);
   2783 
   2784        await feed.refreshAll({ isStartup: true });
   2785 
   2786        // Once from cache, once to update the store
   2787        assert.calledTwice(feed.store.dispatch);
   2788        assert.equal(
   2789          feed.store.dispatch.firstCall.args[0].type,
   2790          at.DISCOVERY_STREAM_SPOCS_UPDATE
   2791        );
   2792      });
   2793      it("should not refresh spocs on startup if it is under THIRTY_MINUTES", async () => {
   2794        feed.loadSpocs.restore();
   2795        sandbox.stub(feed.cache, "get").resolves({
   2796          spocs: { lastUpdated: Date.now() },
   2797        });
   2798        sandbox.stub(feed, "fetchFromEndpoint").resolves("data");
   2799 
   2800        await feed.refreshAll({ isStartup: true });
   2801 
   2802        assert.notCalled(feed.fetchFromEndpoint);
   2803      });
   2804      it("should refresh feeds on startup if it was served from cache", async () => {
   2805        feed.loadComponentFeeds.restore();
   2806 
   2807        const fakeComponents = { components: [{ feed: { url: "foo.com" } }] };
   2808        const fakeLayout = [fakeComponents];
   2809        const fakeDiscoveryStream = {
   2810          DiscoveryStream: {
   2811            layout: fakeLayout,
   2812          },
   2813          Prefs: {
   2814            values: {
   2815              "feeds.section.topstories": true,
   2816              "feeds.system.topstories": true,
   2817            },
   2818          },
   2819        };
   2820        sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
   2821        sandbox.stub(feed, "rotate").callsFake(val => val);
   2822        sandbox
   2823          .stub(feed, "scoreItems")
   2824          .callsFake(val => ({ data: val, filtered: [], personalized: false }));
   2825        sandbox.stub(feed, "filterBlocked").callsFake(val => ({ data: val }));
   2826 
   2827        const fakeCache = {
   2828          feeds: { "foo.com": { lastUpdated: Date.now(), data: ["data"] } },
   2829        };
   2830        sandbox.stub(feed.cache, "get").resolves(fakeCache);
   2831        clock.tick(THIRTY_MINUTES + 1);
   2832        stubOutFetchFromEndpointWithRealisticData();
   2833 
   2834        await feed.refreshAll({ isStartup: true });
   2835 
   2836        assert.calledOnce(feed.fetchFromEndpoint);
   2837        // Once from cache, once to update the feed, once to update that all
   2838        // feeds are done, and once to update scores.
   2839        assert.callCount(feed.store.dispatch, 4);
   2840        assert.equal(
   2841          feed.store.dispatch.secondCall.args[0].type,
   2842          at.DISCOVERY_STREAM_FEEDS_UPDATE
   2843        );
   2844      });
   2845    });
   2846  });
   2847 
   2848  describe("#scoreFeeds", () => {
   2849    beforeEach(() => {
   2850      sandbox.stub(feed.cache, "set").resolves();
   2851      sandbox.spy(feed.store, "dispatch");
   2852    });
   2853    it("should score feeds and set cache, and dispatch", async () => {
   2854      const fakeDiscoveryStream = {
   2855        Prefs: {
   2856          values: {
   2857            "discoverystream.spocs.personalized": true,
   2858            "discoverystream.recs.personalized": false,
   2859          },
   2860        },
   2861        Personalization: {
   2862          initialized: true,
   2863        },
   2864        DiscoveryStream: {
   2865          spocs: {
   2866            placements: [
   2867              { name: "placement1" },
   2868              { name: "placement2" },
   2869              { name: "placement3" },
   2870            ],
   2871          },
   2872        },
   2873      };
   2874      sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
   2875      const fakeFeeds = {
   2876        data: {
   2877          "https://foo.com": {
   2878            data: {
   2879              recommendations: [
   2880                {
   2881                  id: "first",
   2882                  item_score: 0.7,
   2883                },
   2884                {
   2885                  id: "second",
   2886                  item_score: 0.6,
   2887                },
   2888              ],
   2889            },
   2890          },
   2891          "https://bar.com": {
   2892            data: {
   2893              recommendations: [
   2894                {
   2895                  id: "third",
   2896                  item_score: 0.4,
   2897                },
   2898                {
   2899                  id: "fourth",
   2900                  item_score: 0.6,
   2901                },
   2902                {
   2903                  id: "fifth",
   2904                  item_score: 0.8,
   2905                },
   2906              ],
   2907            },
   2908          },
   2909        },
   2910      };
   2911      const feedsTestResult = {
   2912        "https://foo.com": {
   2913          personalized: true,
   2914          data: {
   2915            recommendations: [
   2916              {
   2917                id: "first",
   2918                item_score: 0.7,
   2919                score: 0.7,
   2920              },
   2921              {
   2922                id: "second",
   2923                item_score: 0.6,
   2924                score: 0.6,
   2925              },
   2926            ],
   2927          },
   2928        },
   2929        "https://bar.com": {
   2930          personalized: true,
   2931          data: {
   2932            recommendations: [
   2933              {
   2934                id: "fifth",
   2935                item_score: 0.8,
   2936                score: 0.8,
   2937              },
   2938              {
   2939                id: "fourth",
   2940                item_score: 0.6,
   2941                score: 0.6,
   2942              },
   2943              {
   2944                id: "third",
   2945                item_score: 0.4,
   2946                score: 0.4,
   2947              },
   2948            ],
   2949          },
   2950        },
   2951      };
   2952 
   2953      await feed.scoreFeeds(fakeFeeds);
   2954 
   2955      assert.calledWith(feed.cache.set, "feeds", feedsTestResult);
   2956      assert.equal(
   2957        feed.store.dispatch.firstCall.args[0].type,
   2958        at.DISCOVERY_STREAM_FEED_UPDATE
   2959      );
   2960      assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, {
   2961        url: "https://foo.com",
   2962        feed: feedsTestResult["https://foo.com"],
   2963      });
   2964      assert.equal(
   2965        feed.store.dispatch.secondCall.args[0].type,
   2966        at.DISCOVERY_STREAM_FEED_UPDATE
   2967      );
   2968      assert.deepEqual(feed.store.dispatch.secondCall.args[0].data, {
   2969        url: "https://bar.com",
   2970        feed: feedsTestResult["https://bar.com"],
   2971      });
   2972    });
   2973 
   2974    it("should skip already personalized feeds", async () => {
   2975      sandbox.spy(feed, "scoreItems");
   2976      const recsExpireTime = 5600;
   2977      const fakeFeeds = {
   2978        data: {
   2979          "https://foo.com": {
   2980            personalized: true,
   2981            data: {
   2982              recommendations: [
   2983                {
   2984                  id: "first",
   2985                  item_score: 0.7,
   2986                },
   2987                {
   2988                  id: "second",
   2989                  item_score: 0.6,
   2990                },
   2991              ],
   2992              settings: {
   2993                recsExpireTime,
   2994              },
   2995            },
   2996          },
   2997        },
   2998      };
   2999 
   3000      await feed.scoreFeeds(fakeFeeds);
   3001 
   3002      assert.notCalled(feed.scoreItems);
   3003    });
   3004  });
   3005 
   3006  describe("#scoreSpocs", () => {
   3007    beforeEach(() => {
   3008      sandbox.stub(feed.cache, "set").resolves();
   3009      sandbox.spy(feed.store, "dispatch");
   3010    });
   3011    it("should score spocs and set cache, dispatch", async () => {
   3012      const fakeDiscoveryStream = {
   3013        Prefs: {
   3014          values: {
   3015            "discoverystream.spocs.personalized": true,
   3016            "discoverystream.recs.personalized": false,
   3017          },
   3018        },
   3019        Personalization: {
   3020          initialized: true,
   3021        },
   3022        DiscoveryStream: {
   3023          spocs: {
   3024            placements: [
   3025              { name: "placement1" },
   3026              { name: "placement2" },
   3027              { name: "placement3" },
   3028            ],
   3029          },
   3030        },
   3031      };
   3032      sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
   3033      const fakeSpocs = {
   3034        lastUpdated: 1234,
   3035        data: {
   3036          placement1: {
   3037            items: [
   3038              {
   3039                item_score: 0.6,
   3040              },
   3041              {
   3042                item_score: 0.4,
   3043              },
   3044              {
   3045                item_score: 0.8,
   3046              },
   3047            ],
   3048          },
   3049          placement2: {
   3050            items: [
   3051              {
   3052                item_score: 0.6,
   3053              },
   3054              {
   3055                item_score: 0.8,
   3056              },
   3057            ],
   3058          },
   3059          placement3: { items: [] },
   3060        },
   3061      };
   3062 
   3063      await feed.scoreSpocs(fakeSpocs);
   3064 
   3065      const spocsTestResult = {
   3066        lastUpdated: 1234,
   3067        spocsCacheUpdateTime: 1800000,
   3068        spocsOnDemand: undefined,
   3069        spocs: {
   3070          placement1: {
   3071            personalized: true,
   3072            items: [
   3073              {
   3074                score: 0.8,
   3075                item_score: 0.8,
   3076              },
   3077              {
   3078                score: 0.6,
   3079                item_score: 0.6,
   3080              },
   3081              {
   3082                score: 0.4,
   3083                item_score: 0.4,
   3084              },
   3085            ],
   3086          },
   3087          placement2: {
   3088            personalized: true,
   3089            items: [
   3090              {
   3091                score: 0.8,
   3092                item_score: 0.8,
   3093              },
   3094              {
   3095                score: 0.6,
   3096                item_score: 0.6,
   3097              },
   3098            ],
   3099          },
   3100          placement3: { items: [] },
   3101        },
   3102      };
   3103      assert.calledWith(feed.cache.set, "spocs", spocsTestResult);
   3104      assert.equal(
   3105        feed.store.dispatch.firstCall.args[0].type,
   3106        at.DISCOVERY_STREAM_SPOCS_UPDATE
   3107      );
   3108      assert.deepEqual(
   3109        feed.store.dispatch.firstCall.args[0].data,
   3110        spocsTestResult
   3111      );
   3112    });
   3113 
   3114    it("should skip already personalized spocs", async () => {
   3115      sandbox.spy(feed, "scoreItems");
   3116      const fakeDiscoveryStream = {
   3117        Prefs: {
   3118          values: {
   3119            "discoverystream.spocs.personalized": true,
   3120            "discoverystream.recs.personalized": false,
   3121          },
   3122        },
   3123        Personalization: {
   3124          initialized: true,
   3125        },
   3126        DiscoveryStream: {
   3127          spocs: {
   3128            placements: [{ name: "placement1" }],
   3129          },
   3130        },
   3131      };
   3132      sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
   3133      const fakeSpocs = {
   3134        lastUpdated: 1234,
   3135        data: {
   3136          placement1: {
   3137            personalized: true,
   3138            items: [
   3139              {
   3140                item_score: 0.6,
   3141              },
   3142              {
   3143                item_score: 0.4,
   3144              },
   3145              {
   3146                item_score: 0.8,
   3147              },
   3148            ],
   3149          },
   3150        },
   3151      };
   3152 
   3153      await feed.scoreSpocs(fakeSpocs);
   3154 
   3155      assert.notCalled(feed.scoreItems);
   3156    });
   3157  });
   3158 
   3159  describe("#onAction: DISCOVERY_STREAM_PERSONALIZATION_UPDATED", () => {
   3160    it("should call scoreFeeds and scoreSpocs if loaded", async () => {
   3161      const fakeDiscoveryStream = {
   3162        Prefs: {
   3163          values: {
   3164            pocketConfig: {
   3165              recsPersonalized: true,
   3166              spocsPersonalized: true,
   3167            },
   3168          },
   3169        },
   3170        DiscoveryStream: {
   3171          feeds: { loaded: false },
   3172          spocs: { loaded: false },
   3173        },
   3174      };
   3175 
   3176      sandbox.stub(feed, "scoreFeeds").resolves();
   3177      sandbox.stub(feed, "scoreSpocs").resolves();
   3178      Object.defineProperty(feed, "personalized", { get: () => true });
   3179      sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
   3180 
   3181      await feed.onAction({
   3182        type: at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED,
   3183      });
   3184 
   3185      assert.notCalled(feed.scoreFeeds);
   3186      assert.notCalled(feed.scoreSpocs);
   3187 
   3188      fakeDiscoveryStream.DiscoveryStream.feeds.loaded = true;
   3189      fakeDiscoveryStream.DiscoveryStream.spocs.loaded = true;
   3190 
   3191      await feed.onAction({
   3192        type: at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED,
   3193      });
   3194 
   3195      assert.calledOnce(feed.scoreFeeds);
   3196      assert.calledOnce(feed.scoreSpocs);
   3197    });
   3198  });
   3199 
   3200  describe("#onAction: TOPIC_SELECTION_MAYBE_LATER", () => {
   3201    it("should call topicSelectionMaybeLaterEvent", async () => {
   3202      sandbox.stub(feed, "topicSelectionMaybeLaterEvent").resolves();
   3203      await feed.onAction({
   3204        type: at.TOPIC_SELECTION_MAYBE_LATER,
   3205      });
   3206      assert.calledOnce(feed.topicSelectionMaybeLaterEvent);
   3207    });
   3208  });
   3209 
   3210  describe("#scoreItem", () => {
   3211    it("should call calculateItemRelevanceScore with recommendationProvider with initial score", async () => {
   3212      const item = {
   3213        item_score: 0.6,
   3214      };
   3215      feed.recommendationProvider.store.getState = () => ({
   3216        Prefs: {
   3217          values: {
   3218            pocketConfig: {
   3219              recsPersonalized: true,
   3220              spocsPersonalized: true,
   3221            },
   3222            "discoverystream.personalization.enabled": true,
   3223            "feeds.section.topstories": true,
   3224            "feeds.system.topstories": true,
   3225          },
   3226        },
   3227      });
   3228      feed.recommendationProvider.calculateItemRelevanceScore = sandbox
   3229        .stub()
   3230        .returns();
   3231      const result = await feed.scoreItem(item, true);
   3232      assert.calledOnce(
   3233        feed.recommendationProvider.calculateItemRelevanceScore
   3234      );
   3235      assert.equal(result.score, 0.6);
   3236    });
   3237    it("should fallback to score 1 without an initial score", async () => {
   3238      const item = {};
   3239      feed.store.getState = () => ({
   3240        Prefs: {
   3241          values: {
   3242            "discoverystream.spocs.personalized": true,
   3243            "discoverystream.recs.personalized": true,
   3244            "discoverystream.personalization.enabled": true,
   3245          },
   3246        },
   3247      });
   3248      feed.recommendationProvider.calculateItemRelevanceScore = sandbox
   3249        .stub()
   3250        .returns();
   3251      const result = await feed.scoreItem(item, true);
   3252      assert.equal(result.score, 1);
   3253    });
   3254  });
   3255 
   3256  describe("new proxy feed", () => {
   3257    beforeEach(() => {
   3258      sandbox.stub(global.Region, "home").get(() => "DE");
   3259      sandbox.stub(global.Services.prefs, "getStringPref");
   3260 
   3261      global.Services.prefs.getStringPref
   3262        .withArgs(
   3263          "browser.newtabpage.activity-stream.discoverystream.merino-provider.endpoint"
   3264        )
   3265        .returns("merinoEndpoint");
   3266    });
   3267 
   3268    it("should update to new feed url", async () => {
   3269      await feed.loadLayout(feed.store.dispatch);
   3270      const { layout } = feed.store.getState().DiscoveryStream;
   3271      assert.equal(
   3272        layout[0].components[2].feed.url,
   3273        "https://merinoEndpoint/api/v1/curated-recommendations"
   3274      );
   3275    });
   3276 
   3277    it("should fetch proper data from getComponentFeed", async () => {
   3278      const fakeCache = {};
   3279      sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
   3280      sandbox.stub(feed, "rotate").callsFake(val => val);
   3281      sandbox
   3282        .stub(feed, "scoreItems")
   3283        .callsFake(val => ({ data: val, filtered: [], personalized: false }));
   3284      sandbox.stub(feed, "fetchFromEndpoint").resolves({
   3285        recommendedAt: 1755834072383,
   3286        surfaceId: "NEW_TAB_EN_US",
   3287        data: [
   3288          {
   3289            corpusItemId: "decaf-c0ff33",
   3290            scheduledCorpusItemId: "matcha-latte-ff33c1",
   3291            excerpt: "excerpt",
   3292            iconUrl: "iconUrl",
   3293            imageUrl: "imageUrl",
   3294            isTimeSensitive: true,
   3295            publisher: "publisher",
   3296            receivedRank: 0,
   3297            tileId: 12345,
   3298            title: "title",
   3299            topic: "topic",
   3300            url: "url",
   3301            features: {},
   3302          },
   3303        ],
   3304      });
   3305 
   3306      const feedData = await feed.getComponentFeed("url");
   3307      const expectedData = {
   3308        lastUpdated: 0,
   3309        personalized: false,
   3310        sectionsEnabled: undefined,
   3311        data: {
   3312          settings: {},
   3313          sections: [],
   3314          interestPicker: {},
   3315          recommendations: [
   3316            {
   3317              id: "decaf-c0ff33",
   3318              corpus_item_id: "decaf-c0ff33",
   3319              scheduled_corpus_item_id: "matcha-latte-ff33c1",
   3320              excerpt: "excerpt",
   3321              icon_src: "iconUrl",
   3322              isTimeSensitive: true,
   3323              publisher: "publisher",
   3324              raw_image_src: "imageUrl",
   3325              received_rank: 0,
   3326              recommended_at: 1755834072383,
   3327              title: "title",
   3328              topic: "topic",
   3329              url: "url",
   3330              features: {},
   3331            },
   3332          ],
   3333          surfaceId: "NEW_TAB_EN_US",
   3334          status: "success",
   3335        },
   3336      };
   3337 
   3338      assert.deepEqual(feedData, expectedData);
   3339    });
   3340    it("should fetch proper data from getComponentFeed with sections enabled", async () => {
   3341      setPref("discoverystream.sections.enabled", true);
   3342      const fakeCache = {};
   3343      sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
   3344      sandbox.stub(feed, "rotate").callsFake(val => val);
   3345      sandbox
   3346        .stub(feed, "scoreItems")
   3347        .callsFake(val => ({ data: val, filtered: [], personalized: false }));
   3348      sandbox.stub(feed, "fetchFromEndpoint").resolves({
   3349        recommendedAt: 1755834072383,
   3350        surfaceId: "NEW_TAB_EN_US",
   3351        data: [
   3352          {
   3353            corpusItemId: "decaf-c0ff33",
   3354            scheduledCorpusItemId: "matcha-latte-ff33c1",
   3355            excerpt: "excerpt",
   3356            iconUrl: "iconUrl",
   3357            imageUrl: "imageUrl",
   3358            isTimeSensitive: true,
   3359            publisher: "publisher",
   3360            receivedRank: 0,
   3361            tileId: 12345,
   3362            title: "title",
   3363            topic: "topic",
   3364            url: "url",
   3365            features: {},
   3366          },
   3367        ],
   3368        feeds: {
   3369          "section-1": {
   3370            title: "Section 1",
   3371            subtitle: "Subtitle 1",
   3372            receivedFeedRank: 1,
   3373            layout: "cards",
   3374            iab: "iab-category",
   3375            isInitiallyVisible: true,
   3376            recommendations: [
   3377              {
   3378                corpusItemId: "decaf-c0ff34",
   3379                scheduledCorpusItemId: "matcha-latte-ff33c2",
   3380                excerpt: "section excerpt",
   3381                iconUrl: "sectionIconUrl",
   3382                imageUrl: "sectionImageUrl",
   3383                isTimeSensitive: false,
   3384                publisher: "section publisher",
   3385                serverScore: 0.9,
   3386                receivedRank: 1,
   3387                title: "section title",
   3388                topic: "section topic",
   3389                url: "section url",
   3390                features: {},
   3391              },
   3392            ],
   3393          },
   3394        },
   3395      });
   3396 
   3397      const feedData = await feed.getComponentFeed("url");
   3398      const expectedData = {
   3399        lastUpdated: 0,
   3400        personalized: false,
   3401        sectionsEnabled: true,
   3402        data: {
   3403          settings: {},
   3404          sections: [
   3405            {
   3406              sectionKey: "section-1",
   3407              title: "Section 1",
   3408              subtitle: "Subtitle 1",
   3409              receivedRank: 1,
   3410              layout: "cards",
   3411              iab: "iab-category",
   3412              visible: true,
   3413            },
   3414          ],
   3415          interestPicker: {},
   3416          recommendations: [
   3417            {
   3418              id: "decaf-c0ff33",
   3419              corpus_item_id: "decaf-c0ff33",
   3420              scheduled_corpus_item_id: "matcha-latte-ff33c1",
   3421              excerpt: "excerpt",
   3422              icon_src: "iconUrl",
   3423              isTimeSensitive: true,
   3424              publisher: "publisher",
   3425              raw_image_src: "imageUrl",
   3426              received_rank: 0,
   3427              recommended_at: 1755834072383,
   3428              title: "title",
   3429              topic: "topic",
   3430              url: "url",
   3431              features: {},
   3432            },
   3433            {
   3434              id: "decaf-c0ff34",
   3435              corpus_item_id: "decaf-c0ff34",
   3436              scheduled_corpus_item_id: "matcha-latte-ff33c2",
   3437              excerpt: "section excerpt",
   3438              icon_src: "sectionIconUrl",
   3439              isTimeSensitive: false,
   3440              publisher: "section publisher",
   3441              server_score: 0.9,
   3442              raw_image_src: "sectionImageUrl",
   3443              received_rank: 1,
   3444              recommended_at: 1755834072383,
   3445              title: "section title",
   3446              topic: "section topic",
   3447              url: "section url",
   3448              features: {},
   3449              section: "section-1",
   3450            },
   3451          ],
   3452          surfaceId: "NEW_TAB_EN_US",
   3453          status: "success",
   3454        },
   3455      };
   3456 
   3457      assert.deepEqual(feedData, expectedData);
   3458    });
   3459 
   3460    describe("client layout for sections", () => {
   3461      beforeEach(() => {
   3462        setPref("discoverystream.sections.enabled", true);
   3463        globals.set("DEFAULT_SECTION_LAYOUT", DEFAULT_SECTION_LAYOUT);
   3464        const fakeCache = {};
   3465        sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
   3466        sandbox.stub(feed, "rotate").callsFake(val => val);
   3467        sandbox
   3468          .stub(feed, "scoreItems")
   3469          .callsFake(val => ({ data: val, filtered: [], personalized: false }));
   3470        sandbox.stub(feed, "fetchFromEndpoint").resolves({
   3471          recommendedAt: 1755834072383,
   3472          surfaceId: "NEW_TAB_EN_US",
   3473          data: [],
   3474          feeds: {
   3475            "section-1": {
   3476              title: "Section 1",
   3477              subtitle: "Subtitle 1",
   3478              receivedFeedRank: 1,
   3479              layout: { name: "original-layout" },
   3480              iab: "iab-category",
   3481              isInitiallyVisible: true,
   3482              recommendations: [],
   3483            },
   3484            "section-2": {
   3485              title: "Section 2",
   3486              subtitle: "Subtitle 2",
   3487              receivedFeedRank: 2,
   3488              layout: { name: "another-layout" },
   3489              iab: "iab-category-2",
   3490              isInitiallyVisible: true,
   3491              recommendations: [],
   3492            },
   3493          },
   3494        });
   3495      });
   3496      it("should return default layout when sections.clientLayout.enabled is false and server returns a layout object", async () => {
   3497        const feedData = await feed.getComponentFeed("url");
   3498        assert.equal(feedData.data.sections.length, 2);
   3499        assert.equal(
   3500          feedData.data.sections[0].layout.name,
   3501          "original-layout",
   3502          "First section should use original layout from server"
   3503        );
   3504        assert.equal(
   3505          feedData.data.sections[1].layout.name,
   3506          "another-layout",
   3507          "Second section should use second default layout"
   3508        );
   3509      });
   3510      it("should apply client layout when sections.clientLayout.enabled is true", async () => {
   3511        setPref("discoverystream.sections.clientLayout.enabled", true);
   3512        const feedData = await feed.getComponentFeed("url");
   3513 
   3514        assert.equal(
   3515          feedData.data.sections[0].layout.name,
   3516          "7-double-row-2-ad",
   3517          "First section should use first default layout"
   3518        );
   3519        assert.equal(
   3520          feedData.data.sections[1].layout.name,
   3521          "6-small-medium-1-ad",
   3522          "Second section should use second default layout"
   3523        );
   3524      });
   3525      it("should apply client layout when any section has a missing layout property", async () => {
   3526        feed.fetchFromEndpoint.resolves({
   3527          recommendedAt: 1755834072383,
   3528          surfaceId: "NEW_TAB_EN_US",
   3529          data: [],
   3530          feeds: {
   3531            "section-1": {
   3532              title: "Section 1",
   3533              subtitle: "Subtitle 1",
   3534              receivedFeedRank: 1,
   3535              iab: "iab-category",
   3536              isInitiallyVisible: true,
   3537              recommendations: [],
   3538            },
   3539            "section-2": {
   3540              title: "Section 2",
   3541              subtitle: "Subtitle 2",
   3542              receivedFeedRank: 2,
   3543              layout: { name: "another-layout" },
   3544              iab: "iab-category-2",
   3545              isInitiallyVisible: true,
   3546              recommendations: [],
   3547            },
   3548          },
   3549        });
   3550        const feedData = await feed.getComponentFeed("url");
   3551 
   3552        assert.equal(
   3553          feedData.data.sections[0].layout.name,
   3554          "7-double-row-2-ad",
   3555          "First section without layout should use client default layout"
   3556        );
   3557        assert.equal(
   3558          feedData.data.sections[1].layout.name,
   3559          "another-layout",
   3560          "Second section with layout should keep its original layout"
   3561        );
   3562      });
   3563    });
   3564  });
   3565 
   3566  describe("#getContextualAdsPlacements", () => {
   3567    let prefs;
   3568 
   3569    beforeEach(() => {
   3570      prefs = {
   3571        "discoverystream.placements.contextualSpocs":
   3572          "newtab_stories_1, newtab_stories_2, newtab_stories_3",
   3573        "discoverystream.placements.contextualSpocs.counts": "1, 1, 1",
   3574        "discoverystream.placements.contextualBanners": "",
   3575        "discoverystream.placements.contextualBanners.counts": "",
   3576        "newtabAdSize.leaderboard": false,
   3577        "newtabAdSize.billboard": false,
   3578        "newtabAdSize.leaderboard.position": 3,
   3579        "newtabAdSize.billboard.position": 3,
   3580      };
   3581    });
   3582 
   3583    it("should only return SPOC placements", async () => {
   3584      feed.store.getState = () => ({
   3585        Prefs: {
   3586          values: prefs,
   3587        },
   3588        DiscoveryStream: {
   3589          feeds: {
   3590            data: {
   3591              "https://merino.services.mozilla.com/api/v1/curated-recommendations":
   3592                {
   3593                  data: {
   3594                    sections: [
   3595                      {
   3596                        iab: { taxonomy: "IAB-3.0", categories: ["386"] },
   3597                        receivedRank: 0,
   3598                        layout: {
   3599                          responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
   3600                        },
   3601                      },
   3602                      {
   3603                        iab: { taxonomy: "IAB-3.0", categories: ["52"] },
   3604                        receivedRank: 1,
   3605                        layout: {
   3606                          responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
   3607                        },
   3608                      },
   3609                      {
   3610                        iab: { taxonomy: "IAB-3.0", categories: ["464"] },
   3611                        receivedRank: 1,
   3612                        layout: {
   3613                          responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
   3614                        },
   3615                      },
   3616                    ],
   3617                  },
   3618                },
   3619            },
   3620          },
   3621        },
   3622      });
   3623 
   3624      const placements = feed.getContextualAdsPlacements();
   3625 
   3626      assert.deepEqual(placements, [
   3627        {
   3628          placement: "newtab_stories_1",
   3629          count: 1,
   3630          content: {
   3631            taxonomy: "IAB-3.0",
   3632            categories: ["386"],
   3633          },
   3634        },
   3635        {
   3636          placement: "newtab_stories_2",
   3637          count: 1,
   3638          content: {
   3639            taxonomy: "IAB-3.0",
   3640            categories: ["52"],
   3641          },
   3642        },
   3643        {
   3644          placement: "newtab_stories_3",
   3645          count: 1,
   3646          content: {
   3647            taxonomy: "IAB-3.0",
   3648            categories: ["464"],
   3649          },
   3650        },
   3651      ]);
   3652    });
   3653 
   3654    it("should return SPOC placements AND banner placements when leaderboard is enabled", async () => {
   3655      // Updating the prefs object keys to have the banner values ready for the test
   3656      prefs["discoverystream.placements.contextualBanners"] =
   3657        "newtab_leaderboard";
   3658      prefs["discoverystream.placements.contextualBanners.counts"] = "1";
   3659      prefs["newtabAdSize.leaderboard"] = true;
   3660      prefs["newtabAdSize.leaderboard.position"] = 2;
   3661 
   3662      feed.store.getState = () => ({
   3663        Prefs: {
   3664          values: prefs,
   3665        },
   3666        DiscoveryStream: {
   3667          feeds: {
   3668            data: {
   3669              "https://merino.services.mozilla.com/api/v1/curated-recommendations":
   3670                {
   3671                  data: {
   3672                    sections: [
   3673                      {
   3674                        iab: { taxonomy: "IAB-3.0", categories: ["386"] },
   3675                        receivedRank: 0,
   3676                        layout: {
   3677                          responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
   3678                        },
   3679                      },
   3680                      {
   3681                        iab: { taxonomy: "IAB-3.0", categories: ["52"] },
   3682                        receivedRank: 1,
   3683                        layout: {
   3684                          responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
   3685                        },
   3686                      },
   3687                      {
   3688                        iab: { taxonomy: "IAB-3.0", categories: ["464"] },
   3689                        receivedRank: 1,
   3690                        layout: {
   3691                          responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
   3692                        },
   3693                      },
   3694                    ],
   3695                  },
   3696                },
   3697            },
   3698          },
   3699        },
   3700      });
   3701 
   3702      const placements = feed.getContextualAdsPlacements();
   3703 
   3704      assert.deepEqual(placements, [
   3705        {
   3706          placement: "newtab_stories_1",
   3707          count: 1,
   3708          content: {
   3709            taxonomy: "IAB-3.0",
   3710            categories: ["386"],
   3711          },
   3712        },
   3713        {
   3714          placement: "newtab_stories_2",
   3715          count: 1,
   3716          content: {
   3717            taxonomy: "IAB-3.0",
   3718            categories: ["52"],
   3719          },
   3720        },
   3721        {
   3722          placement: "newtab_stories_3",
   3723          count: 1,
   3724          content: {
   3725            taxonomy: "IAB-3.0",
   3726            categories: ["464"],
   3727          },
   3728        },
   3729        {
   3730          placement: "newtab_leaderboard",
   3731          count: 1,
   3732          content: {
   3733            taxonomy: "IAB-3.0",
   3734            categories: ["386"],
   3735          },
   3736        },
   3737      ]);
   3738    });
   3739 
   3740    it("should return SPOC placements AND banner placements when billboard is enabled", async () => {
   3741      // Updating the prefs object keys to have the banner values ready for the test
   3742      prefs["discoverystream.placements.contextualBanners"] =
   3743        "newtab_billboard";
   3744      prefs["discoverystream.placements.contextualBanners.counts"] = "1";
   3745      prefs["newtabAdSize.billboard"] = true;
   3746      prefs["newtabAdSize.billboard.position"] = 2;
   3747 
   3748      feed.store.getState = () => ({
   3749        Prefs: {
   3750          values: prefs,
   3751        },
   3752        DiscoveryStream: {
   3753          feeds: {
   3754            data: {
   3755              "https://merino.services.mozilla.com/api/v1/curated-recommendations":
   3756                {
   3757                  data: {
   3758                    sections: [
   3759                      {
   3760                        iab: { taxonomy: "IAB-3.0", categories: ["386"] },
   3761                        receivedRank: 0,
   3762                        layout: {
   3763                          responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
   3764                        },
   3765                      },
   3766                      {
   3767                        iab: { taxonomy: "IAB-3.0", categories: ["52"] },
   3768                        receivedRank: 1,
   3769                        layout: {
   3770                          responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
   3771                        },
   3772                      },
   3773                      {
   3774                        iab: { taxonomy: "IAB-3.0", categories: ["464"] },
   3775                        receivedRank: 1,
   3776                        layout: {
   3777                          responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
   3778                        },
   3779                      },
   3780                    ],
   3781                  },
   3782                },
   3783            },
   3784          },
   3785        },
   3786      });
   3787 
   3788      const placements = feed.getContextualAdsPlacements();
   3789 
   3790      assert.deepEqual(placements, [
   3791        {
   3792          placement: "newtab_stories_1",
   3793          count: 1,
   3794          content: {
   3795            taxonomy: "IAB-3.0",
   3796            categories: ["386"],
   3797          },
   3798        },
   3799        {
   3800          placement: "newtab_stories_2",
   3801          count: 1,
   3802          content: {
   3803            taxonomy: "IAB-3.0",
   3804            categories: ["52"],
   3805          },
   3806        },
   3807        {
   3808          placement: "newtab_stories_3",
   3809          count: 1,
   3810          content: {
   3811            taxonomy: "IAB-3.0",
   3812            categories: ["464"],
   3813          },
   3814        },
   3815        {
   3816          placement: "newtab_billboard",
   3817          count: 1,
   3818          content: {
   3819            taxonomy: "IAB-3.0",
   3820            categories: ["386"],
   3821          },
   3822        },
   3823      ]);
   3824    });
   3825  });
   3826 });