diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js | |
parent | Initial commit. (diff) | |
download | firefox-esr-upstream.tar.xz firefox-esr-upstream.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js')
-rw-r--r-- | browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js | 3581 |
1 files changed, 3581 insertions, 0 deletions
diff --git a/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js new file mode 100644 index 0000000000..e91b7fc549 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js @@ -0,0 +1,3581 @@ +import { + actionCreators as ac, + actionTypes as at, + actionUtils as au, +} from "common/Actions.sys.mjs"; +import { combineReducers, createStore } from "redux"; +import { GlobalOverrider } from "test/unit/utils"; +import { DiscoveryStreamFeed } from "lib/DiscoveryStreamFeed.jsm"; +import { RecommendationProvider } from "lib/RecommendationProvider.jsm"; +import { reducers } from "common/Reducers.sys.mjs"; + +import { PersistentCache } from "lib/PersistentCache.sys.mjs"; +import { PersonalityProvider } from "lib/PersonalityProvider/PersonalityProvider.jsm"; + +const CONFIG_PREF_NAME = "discoverystream.config"; +const DUMMY_ENDPOINT = "https://getpocket.cdn.mozilla.net/dummy"; +const ENDPOINTS_PREF_NAME = "discoverystream.endpoints"; +const SPOC_IMPRESSION_TRACKING_PREF = "discoverystream.spoc.impressions"; +const REC_IMPRESSION_TRACKING_PREF = "discoverystream.rec.impressions"; +const THIRTY_MINUTES = 30 * 60 * 1000; +const ONE_WEEK = 7 * 24 * 60 * 60 * 1000; // 1 week + +const FAKE_UUID = "{foo-123-foo}"; + +// eslint-disable-next-line max-statements +describe("DiscoveryStreamFeed", () => { + let feed; + let feeds; + let recommendationProvider; + let sandbox; + let fetchStub; + let clock; + let fakeNewTabUtils; + let fakePktApi; + let globals; + + const setPref = (name, value) => { + const action = { + type: at.PREF_CHANGED, + data: { + name, + value: typeof value === "object" ? JSON.stringify(value) : value, + }, + }; + feed.store.dispatch(action); + feed.onAction(action); + }; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + // Fetch + fetchStub = sandbox.stub(global, "fetch"); + + // Time + clock = sinon.useFakeTimers(); + + globals = new GlobalOverrider(); + globals.set({ + gUUIDGenerator: { generateUUID: () => FAKE_UUID }, + PersistentCache, + PersonalityProvider, + }); + + sandbox + .stub(global.Services.prefs, "getBoolPref") + .withArgs("browser.newtabpage.activity-stream.discoverystream.enabled") + .returns(true); + + recommendationProvider = new RecommendationProvider(); + recommendationProvider.store = createStore(combineReducers(reducers), {}); + feeds = { + "feeds.recommendationprovider": recommendationProvider, + }; + + // Feed + feed = new DiscoveryStreamFeed(); + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: false, + show_spocs: false, + layout_endpoint: DUMMY_ENDPOINT, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "feeds.section.topstories": true, + "feeds.system.topstories": true, + "discoverystream.spocs.personalized": true, + "discoverystream.recs.personalized": true, + }, + }, + }); + feed.store.feeds = { + get: name => feeds[name], + }; + global.fetch.resetHistory(); + + sandbox.stub(feed, "_maybeUpdateCachedData").resolves(); + + globals.set("setTimeout", callback => { + callback(); + }); + + fakeNewTabUtils = { + blockedLinks: { + links: [], + isBlocked: () => false, + }, + }; + globals.set("NewTabUtils", fakeNewTabUtils); + + fakePktApi = { + isUserLoggedIn: () => false, + getRecentSavesCache: () => null, + getRecentSaves: () => null, + }; + globals.set("pktApi", fakePktApi); + }); + + afterEach(() => { + clock.restore(); + sandbox.restore(); + globals.restore(); + }); + + describe("#fetchFromEndpoint", () => { + beforeEach(() => { + feed._prefCache = { + config: { + api_key_pref: "", + }, + }; + fetchStub.resolves({ + json: () => Promise.resolve("hi"), + ok: true, + }); + }); + it("should get a response", async () => { + const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT); + + assert.equal(response, "hi"); + }); + it("should not send cookies", async () => { + await feed.fetchFromEndpoint(DUMMY_ENDPOINT); + + assert.propertyVal(fetchStub.firstCall.args[1], "credentials", "omit"); + }); + it("should allow unexpected response", async () => { + fetchStub.resolves({ ok: false }); + + const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT); + + assert.equal(response, null); + }); + it("should disallow unexpected endpoints", async () => { + feed.store.getState = () => ({ + Prefs: { values: { [ENDPOINTS_PREF_NAME]: "https://other.site" } }, + }); + + const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT); + + assert.equal(response, null); + }); + it("should allow multiple endpoints", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + [ENDPOINTS_PREF_NAME]: `https://other.site,${DUMMY_ENDPOINT}`, + }, + }, + }); + + const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT); + + assert.equal(response, "hi"); + }); + it("should replace urls with $apiKey", async () => { + sandbox.stub(global.Services.prefs, "getCharPref").returns("replaced"); + + await feed.fetchFromEndpoint( + "https://getpocket.cdn.mozilla.net/dummy?consumer_key=$apiKey" + ); + + assert.calledWithMatch( + fetchStub, + "https://getpocket.cdn.mozilla.net/dummy?consumer_key=replaced", + { credentials: "omit" } + ); + }); + it("should replace locales with $locale", async () => { + feed.locale = "replaced"; + await feed.fetchFromEndpoint( + "https://getpocket.cdn.mozilla.net/dummy?locale_lang=$locale" + ); + + assert.calledWithMatch( + fetchStub, + "https://getpocket.cdn.mozilla.net/dummy?locale_lang=replaced", + { credentials: "omit" } + ); + }); + it("should allow POST and with other options", async () => { + await feed.fetchFromEndpoint("https://getpocket.cdn.mozilla.net/dummy", { + method: "POST", + body: "{}", + }); + + assert.calledWithMatch( + fetchStub, + "https://getpocket.cdn.mozilla.net/dummy", + { + credentials: "omit", + method: "POST", + body: "{}", + } + ); + }); + }); + + describe("#setupPocketState", () => { + it("should setup logged in state and recent saves with cache", async () => { + fakePktApi.isUserLoggedIn = () => true; + fakePktApi.getRecentSavesCache = () => [1, 2, 3]; + sandbox.spy(feed.store, "dispatch"); + await feed.setupPocketState({}); + assert.calledTwice(feed.store.dispatch); + assert.calledWith( + feed.store.dispatch.firstCall, + ac.OnlyToOneContent( + { + type: at.DISCOVERY_STREAM_POCKET_STATE_SET, + data: { isUserLoggedIn: true }, + }, + {} + ) + ); + assert.calledWith( + feed.store.dispatch.secondCall, + ac.OnlyToOneContent( + { + type: at.DISCOVERY_STREAM_RECENT_SAVES, + data: { recentSaves: [1, 2, 3] }, + }, + {} + ) + ); + }); + it("should setup logged in state and recent saves without cache", async () => { + fakePktApi.isUserLoggedIn = () => true; + fakePktApi.getRecentSaves = ({ success }) => success([1, 2, 3]); + sandbox.spy(feed.store, "dispatch"); + await feed.setupPocketState({}); + assert.calledTwice(feed.store.dispatch); + assert.calledWith( + feed.store.dispatch.firstCall, + ac.OnlyToOneContent( + { + type: at.DISCOVERY_STREAM_POCKET_STATE_SET, + data: { isUserLoggedIn: true }, + }, + {} + ) + ); + assert.calledWith( + feed.store.dispatch.secondCall, + ac.OnlyToOneContent( + { + type: at.DISCOVERY_STREAM_RECENT_SAVES, + data: { recentSaves: [1, 2, 3] }, + }, + {} + ) + ); + }); + }); + + describe("#getOrCreateImpressionId", () => { + it("should create impression id in constructor", async () => { + assert.equal(feed._impressionId, FAKE_UUID); + }); + it("should create impression id if none exists", async () => { + sandbox.stub(global.Services.prefs, "getCharPref").returns(""); + sandbox.stub(global.Services.prefs, "setCharPref").returns(); + + const result = feed.getOrCreateImpressionId(); + + assert.equal(result, FAKE_UUID); + assert.calledOnce(global.Services.prefs.setCharPref); + }); + it("should use impression id if exists", async () => { + sandbox.stub(global.Services.prefs, "getCharPref").returns("from get"); + + const result = feed.getOrCreateImpressionId(); + + assert.equal(result, "from get"); + assert.calledOnce(global.Services.prefs.getCharPref); + }); + }); + + describe("#parseGridPositions", () => { + it("should return an equivalent array for an array of non negative integers", async () => { + assert.deepEqual(feed.parseGridPositions([0, 2, 3]), [0, 2, 3]); + }); + it("should return undefined for an array containing negative integers", async () => { + assert.equal(feed.parseGridPositions([-2, 2, 3]), undefined); + }); + it("should return undefined for an undefined input", async () => { + assert.equal(feed.parseGridPositions(undefined), undefined); + }); + }); + + describe("#loadLayout", () => { + it("should fetch data and populate the cache if it is empty", async () => { + const resp = { layout: ["foo", "bar"] }; + const fakeCache = {}; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + fetchStub.resolves({ ok: true, json: () => Promise.resolve(resp) }); + + await feed.loadLayout(feed.store.dispatch); + + assert.calledOnce(fetchStub); + assert.equal(feed.cache.set.firstCall.args[0], "layout"); + assert.deepEqual(feed.cache.set.firstCall.args[1].layout, resp.layout); + }); + it("should fetch data and populate the cache if the cached data is older than 30 mins", async () => { + const resp = { layout: ["foo", "bar"] }; + const fakeCache = { + layout: { layout: ["hello"], lastUpdated: Date.now() }, + }; + + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + fetchStub.resolves({ ok: true, json: () => Promise.resolve(resp) }); + + clock.tick(THIRTY_MINUTES + 1); + await feed.loadLayout(feed.store.dispatch); + + assert.calledOnce(fetchStub); + assert.equal(feed.cache.set.firstCall.args[0], "layout"); + assert.deepEqual(feed.cache.set.firstCall.args[1].layout, resp.layout); + }); + it("should use the cached data and not fetch if the cached data is less than 30 mins old", async () => { + const fakeCache = { + layout: { layout: ["hello"], lastUpdated: Date.now() }, + }; + + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + clock.tick(THIRTY_MINUTES - 1); + await feed.loadLayout(feed.store.dispatch); + + assert.notCalled(fetchStub); + assert.notCalled(feed.cache.set); + }); + it("should set spocs_endpoint from layout", async () => { + const resp = { layout: ["foo", "bar"], spocs: { url: "foo.com" } }; + const fakeCache = {}; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + fetchStub.resolves({ ok: true, json: () => Promise.resolve(resp) }); + + await feed.loadLayout(feed.store.dispatch); + + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "foo.com" + ); + }); + it("should use local layout with hardcoded_layout being true", async () => { + feed.config.hardcoded_layout = true; + sandbox.stub(feed, "fetchLayout").returns(Promise.resolve("")); + + await feed.loadLayout(feed.store.dispatch); + + assert.notCalled(feed.fetchLayout); + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs" + ); + }); + it("should use local basic layout with hardcoded_layout and hardcoded_basic_layout being true", async () => { + feed.config.hardcoded_layout = true; + feed.config.hardcoded_basic_layout = true; + sandbox.stub(feed, "fetchLayout").returns(Promise.resolve("")); + + await feed.loadLayout(feed.store.dispatch); + + assert.notCalled(feed.fetchLayout); + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs" + ); + const { layout } = feed.store.getState().DiscoveryStream; + assert.equal(layout[0].components[2].properties.items, 3); + }); + it("should use 1 row layout if specified", async () => { + feed.config.hardcoded_layout = true; + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: true, + show_spocs: false, + layout_endpoint: DUMMY_ENDPOINT, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "discoverystream.region-basic-layout": true, + }, + }, + }); + sandbox.stub(feed, "fetchLayout").returns(Promise.resolve("")); + + await feed.loadLayout(feed.store.dispatch); + + const { layout } = feed.store.getState().DiscoveryStream; + assert.equal(layout[0].components[2].properties.items, 3); + }); + it("should use 7 row layout if specified", async () => { + feed.config.hardcoded_layout = true; + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: true, + show_spocs: false, + layout_endpoint: DUMMY_ENDPOINT, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "discoverystream.region-basic-layout": false, + }, + }, + }); + sandbox.stub(feed, "fetchLayout").returns(Promise.resolve("")); + + await feed.loadLayout(feed.store.dispatch); + + const { layout } = feed.store.getState().DiscoveryStream; + assert.equal(layout[0].components[2].properties.items, 21); + }); + it("should use new spocs endpoint if in the config", async () => { + feed.config.spocs_endpoint = "https://spocs.getpocket.com/spocs2"; + + await feed.loadLayout(feed.store.dispatch); + + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs2" + ); + }); + it("should use local basic layout with hardcoded_layout and FF pref hardcoded_basic_layout", async () => { + feed.config.hardcoded_layout = true; + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: false, + show_spocs: false, + layout_endpoint: DUMMY_ENDPOINT, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "discoverystream.hardcoded-basic-layout": true, + }, + }, + }); + + sandbox.stub(feed, "fetchLayout").returns(Promise.resolve("")); + + await feed.loadLayout(feed.store.dispatch); + + assert.notCalled(feed.fetchLayout); + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs" + ); + const { layout } = feed.store.getState().DiscoveryStream; + assert.equal(layout[0].components[2].properties.items, 3); + }); + it("should use new spocs endpoint if in a FF pref", async () => { + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: false, + show_spocs: false, + layout_endpoint: DUMMY_ENDPOINT, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "discoverystream.spocs-endpoint": + "https://spocs.getpocket.com/spocs2", + }, + }, + }); + + await feed.loadLayout(feed.store.dispatch); + + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs2" + ); + }); + it("should fetch local layout for invalid layout endpoint or when fetch layout fails", async () => { + feed.config.hardcoded_layout = false; + fetchStub.resolves({ ok: false }); + + await feed.loadLayout(feed.store.dispatch, true); + + assert.calledOnce(fetchStub); + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs" + ); + }); + it("should return enough stories to fill a four card layout", async () => { + feed.config.hardcoded_layout = true; + + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + pocketConfig: { fourCardLayout: true }, + }, + }, + }); + + sandbox.stub(feed, "fetchLayout").returns(Promise.resolve("")); + + await feed.loadLayout(feed.store.dispatch); + + const { layout } = feed.store.getState().DiscoveryStream; + assert.equal(layout[0].components[2].properties.items, 24); + }); + it("should create a layout with spoc and widget positions", async () => { + feed.config.hardcoded_layout = true; + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + pocketConfig: { + spocPositions: "1, 2", + widgetPositions: "3, 4", + }, + }, + }, + }); + + await feed.loadLayout(feed.store.dispatch); + + const { layout } = feed.store.getState().DiscoveryStream; + assert.deepEqual(layout[0].components[2].spocs.positions, [ + { index: 1 }, + { index: 2 }, + ]); + assert.deepEqual(layout[0].components[2].widgets.positions, [ + { index: 3 }, + { index: 4 }, + ]); + }); + it("should create a layout with spoc position data", async () => { + feed.config.hardcoded_layout = true; + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + pocketConfig: { + spocAdTypes: "1230", + spocZoneIds: "4560, 7890", + }, + }, + }, + }); + + await feed.loadLayout(feed.store.dispatch); + + const { layout } = feed.store.getState().DiscoveryStream; + assert.deepEqual(layout[0].components[2].placement.ad_types, [1230]); + assert.deepEqual( + layout[0].components[2].placement.zone_ids, + [4560, 7890] + ); + }); + it("should create a layout with spoc topsite position data", async () => { + feed.config.hardcoded_layout = true; + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + pocketConfig: { + spocTopsitesAdTypes: "1230", + spocTopsitesZoneIds: "4560, 7890", + }, + }, + }, + }); + + await feed.loadLayout(feed.store.dispatch); + + const { layout } = feed.store.getState().DiscoveryStream; + assert.deepEqual(layout[0].components[0].placement.ad_types, [1230]); + assert.deepEqual( + layout[0].components[0].placement.zone_ids, + [4560, 7890] + ); + }); + it("should create a layout with proper spoc url with a site id", async () => { + feed.config.hardcoded_layout = true; + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + pocketConfig: { + spocSiteId: "1234", + }, + }, + }, + }); + + await feed.loadLayout(feed.store.dispatch); + const { spocs } = feed.store.getState().DiscoveryStream; + assert.deepEqual( + spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs?site=1234" + ); + }); + }); + + describe("#updatePlacements", () => { + it("should dispatch DISCOVERY_STREAM_SPOCS_PLACEMENTS", () => { + sandbox.spy(feed.store, "dispatch"); + feed.store.getState = () => ({ + Prefs: { values: { showSponsored: true } }, + }); + Object.defineProperty(feed, "config", { + get: () => ({ show_spocs: true }), + }); + const fakeComponents = { + components: [ + { placement: { name: "first" }, spocs: {} }, + { placement: { name: "second" }, spocs: {} }, + ], + }; + const fakeLayout = [fakeComponents]; + + feed.updatePlacements(feed.store.dispatch, fakeLayout); + + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + type: "DISCOVERY_STREAM_SPOCS_PLACEMENTS", + data: { placements: [{ name: "first" }, { name: "second" }] }, + meta: { isStartup: false }, + }); + }); + it("should dispatch DISCOVERY_STREAM_SPOCS_PLACEMENTS with prefs array", () => { + sandbox.spy(feed.store, "dispatch"); + feed.store.getState = () => ({ + Prefs: { values: { showSponsored: true, withPref: true } }, + }); + Object.defineProperty(feed, "config", { + get: () => ({ show_spocs: true }), + }); + const fakeComponents = { + components: [ + { placement: { name: "withPref" }, spocs: { prefs: ["withPref"] } }, + { placement: { name: "withoutPref1" }, spocs: {} }, + { + placement: { name: "withoutPref2" }, + spocs: { prefs: ["whatever"] }, + }, + { placement: { name: "withoutPref3" }, spocs: { prefs: [] } }, + ], + }; + const fakeLayout = [fakeComponents]; + + feed.updatePlacements(feed.store.dispatch, fakeLayout); + + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + type: "DISCOVERY_STREAM_SPOCS_PLACEMENTS", + data: { placements: [{ name: "withPref" }, { name: "withoutPref1" }] }, + meta: { isStartup: false }, + }); + }); + it("should fire update placements from loadLayout", async () => { + sandbox.spy(feed, "updatePlacements"); + + await feed.loadLayout(feed.store.dispatch); + + assert.calledOnce(feed.updatePlacements); + }); + }); + + describe("#placementsForEach", () => { + it("should forEach through placements", () => { + feed.store.getState = () => ({ + DiscoveryStream: { + spocs: { + placements: [{ name: "first" }, { name: "second" }], + }, + }, + }); + + let items = []; + + feed.placementsForEach(item => items.push(item.name)); + + assert.deepEqual(items, ["first", "second"]); + }); + }); + + describe("#loadLayoutEndPointUsingPref", () => { + it("should return endpoint if valid key", async () => { + const endpoint = feed.finalLayoutEndpoint( + "https://somedomain.org/stories?consumer_key=$apiKey", + "test_key_val" + ); + assert.equal( + "https://somedomain.org/stories?consumer_key=test_key_val", + endpoint + ); + }); + + it("should throw error if key is empty", async () => { + assert.throws(() => { + feed.finalLayoutEndpoint( + "https://somedomain.org/stories?consumer_key=$apiKey", + "" + ); + }); + }); + + it("should return url if $apiKey is missing in layout_endpoint", async () => { + const endpoint = feed.finalLayoutEndpoint( + "https://somedomain.org/stories?consumer_key=", + "test_key_val" + ); + assert.equal("https://somedomain.org/stories?consumer_key=", endpoint); + }); + + it("should update config layout_endpoint based on api_key_pref value", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + api_key_pref: "test_api_key_pref", + enabled: true, + layout_endpoint: + "https://somedomain.org/stories?consumer_key=$apiKey", + }), + }, + }, + }); + sandbox + .stub(global.Services.prefs, "getCharPref") + .returns("test_api_key_val"); + assert.equal( + "https://somedomain.org/stories?consumer_key=test_api_key_val", + feed.config.layout_endpoint + ); + }); + + it("should not update config layout_endpoint if api_key_pref missing", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: true, + layout_endpoint: + "https://somedomain.org/stories?consumer_key=1234", + }), + }, + }, + }); + sandbox + .stub(global.Services.prefs, "getCharPref") + .returns("test_api_key_val"); + assert.notCalled(global.Services.prefs.getCharPref); + assert.equal( + "https://somedomain.org/stories?consumer_key=1234", + feed.config.layout_endpoint + ); + }); + + it("should not set config layout_endpoint if layout_endpoint missing in prefs", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: true, + }), + }, + }, + }); + sandbox + .stub(global.Services.prefs, "getCharPref") + .returns("test_api_key_val"); + assert.notCalled(global.Services.prefs.getCharPref); + assert.isUndefined(feed.config.layout_endpoint); + }); + }); + + describe("#loadComponentFeeds", () => { + let fakeCache; + let fakeDiscoveryStream; + beforeEach(() => { + fakeDiscoveryStream = { + Prefs: {}, + DiscoveryStream: { + layout: [ + { components: [{ feed: { url: "foo.com" } }] }, + { components: [{}] }, + {}, + ], + }, + }; + fakeCache = {}; + sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should not dispatch updates when layout is not defined", async () => { + fakeDiscoveryStream = { + DiscoveryStream: {}, + }; + feed.store.getState.returns(fakeDiscoveryStream); + sandbox.spy(feed.store, "dispatch"); + + await feed.loadComponentFeeds(feed.store.dispatch); + + assert.notCalled(feed.store.dispatch); + }); + + it("should populate feeds cache", async () => { + fakeCache = { + feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } }, + }; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + + await feed.loadComponentFeeds(feed.store.dispatch); + + assert.calledWith(feed.cache.set, "feeds", { + "foo.com": { data: "data", lastUpdated: 0 }, + }); + }); + + it("should send feed update events with new feed data", async () => { + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.spy(feed.store, "dispatch"); + feed._prefCache = { + config: { + api_key_pref: "", + }, + }; + + await feed.loadComponentFeeds(feed.store.dispatch); + + assert.calledWith(feed.store.dispatch.firstCall, { + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: { data: { status: "failed" } }, url: "foo.com" }, + meta: { isStartup: false }, + }); + assert.calledWith(feed.store.dispatch.secondCall, { + type: at.DISCOVERY_STREAM_FEEDS_UPDATE, + meta: { isStartup: false }, + }); + }); + + it("should return number of promises equal to unique urls", async () => { + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(global.Promise, "all").resolves(); + fakeDiscoveryStream = { + DiscoveryStream: { + layout: [ + { + components: [ + { feed: { url: "foo.com" } }, + { feed: { url: "bar.com" } }, + ], + }, + { components: [{ feed: { url: "foo.com" } }] }, + {}, + { components: [{ feed: { url: "baz.com" } }] }, + ], + }, + }; + feed.store.getState.returns(fakeDiscoveryStream); + + await feed.loadComponentFeeds(feed.store.dispatch); + + assert.calledOnce(global.Promise.all); + const { args } = global.Promise.all.firstCall; + assert.equal(args[0].length, 3); + }); + }); + + describe("#getComponentFeed", () => { + it("should fetch fresh feed data if cache is empty", async () => { + const fakeCache = {}; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed, "rotate").callsFake(val => val); + sandbox + .stub(feed, "scoreItems") + .callsFake(val => ({ data: val, filtered: [] })); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ + recommendations: "data", + settings: { + recsExpireTime: 1, + }, + }); + + const feedResp = await feed.getComponentFeed("foo.com"); + + assert.equal(feedResp.data.recommendations, "data"); + }); + it("should fetch fresh feed data if cache is old", async () => { + const fakeCache = { feeds: { "foo.com": { lastUpdated: Date.now() } } }; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ + recommendations: "data", + settings: { + recsExpireTime: 1, + }, + }); + sandbox.stub(feed, "rotate").callsFake(val => val); + sandbox + .stub(feed, "scoreItems") + .callsFake(val => ({ data: val, filtered: [] })); + clock.tick(THIRTY_MINUTES + 1); + + const feedResp = await feed.getComponentFeed("foo.com"); + + assert.equal(feedResp.data.recommendations, "data"); + }); + it("should return feed data from cache if it is fresh", async () => { + const fakeCache = { + feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } }, + }; + sandbox.stub(feed.cache, "get").resolves(fakeCache); + sandbox.stub(feed, "fetchFromEndpoint").resolves("old data"); + clock.tick(THIRTY_MINUTES - 1); + + const feedResp = await feed.getComponentFeed("foo.com"); + + assert.equal(feedResp.data, "data"); + }); + it("should return null if no response was received", async () => { + sandbox.stub(feed, "fetchFromEndpoint").resolves(null); + + const feedResp = await feed.getComponentFeed("foo.com"); + + assert.deepEqual(feedResp, { data: { status: "failed" } }); + }); + }); + + describe("#personalizationOverride", () => { + it("should dispatch setPref", async () => { + sandbox.spy(feed.store, "dispatch"); + feed.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.personalization.enabled": true, + }, + }, + }); + + feed.personalizationOverride(true); + + assert.calledWithMatch(feed.store.dispatch, { + data: { + name: "discoverystream.personalization.override", + value: true, + }, + type: at.SET_PREF, + }); + }); + it("should dispatch CLEAR_PREF", async () => { + sandbox.spy(feed.store, "dispatch"); + feed.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.personalization.enabled": true, + "discoverystream.personalization.override": true, + }, + }, + }); + + feed.personalizationOverride(false); + + assert.calledWithMatch(feed.store.dispatch, { + data: { + name: "discoverystream.personalization.override", + }, + type: at.CLEAR_PREF, + }); + }); + }); + + describe("#loadSpocs", () => { + beforeEach(() => { + feed._prefCache = { + config: { + api_key_pref: "", + }, + }; + + sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]); + Object.defineProperty(feed, "showSpocs", { get: () => true }); + }); + it("should not fetch or update cache if no spocs endpoint is defined", async () => { + feed.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT, + data: "", + }) + ); + + sandbox.spy(feed.cache, "set"); + + await feed.loadSpocs(feed.store.dispatch); + + assert.notCalled(global.fetch); + assert.notCalled(feed.cache.set); + }); + it("should fetch fresh spocs data if cache is empty", async () => { + sandbox.stub(feed.cache, "get").returns(Promise.resolve()); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "data" }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + await feed.loadSpocs(feed.store.dispatch); + + assert.calledWith(feed.cache.set, "spocs", { + spocs: { placement: "data" }, + lastUpdated: 0, + }); + assert.equal( + feed.store.getState().DiscoveryStream.spocs.data.placement, + "data" + ); + }); + it("should fetch fresh data if cache is old", async () => { + const cachedSpoc = { + spocs: { placement: "old" }, + lastUpdated: Date.now(), + }; + const cachedData = { spocs: cachedSpoc }; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(cachedData)); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "new" }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + clock.tick(THIRTY_MINUTES + 1); + + await feed.loadSpocs(feed.store.dispatch); + + assert.equal( + feed.store.getState().DiscoveryStream.spocs.data.placement, + "new" + ); + }); + it("should return spoc data from cache if it is fresh", async () => { + const cachedSpoc = { + spocs: { placement: "old" }, + lastUpdated: Date.now(), + }; + const cachedData = { spocs: cachedSpoc }; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(cachedData)); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "new" }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + clock.tick(THIRTY_MINUTES - 1); + + await feed.loadSpocs(feed.store.dispatch); + + assert.equal( + feed.store.getState().DiscoveryStream.spocs.data.placement, + "old" + ); + }); + it("should properly transform spocs using placements", async () => { + sandbox.stub(feed.cache, "get").returns(Promise.resolve()); + sandbox + .stub(feed, "fetchFromEndpoint") + .resolves({ spocs: { items: [{ id: "data" }] } }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + await feed.loadSpocs(feed.store.dispatch); + + assert.calledWith(feed.cache.set, "spocs", { + spocs: { + spocs: { + context: "", + title: "", + sponsor: "", + sponsored_by_override: undefined, + items: [{ id: "data", score: 1 }], + }, + }, + lastUpdated: 0, + }); + + assert.deepEqual( + feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0], + { id: "data", score: 1 } + ); + }); + it("should normalizeSpocsItems for older spoc data", async () => { + sandbox.stub(feed.cache, "get").returns(Promise.resolve()); + sandbox + .stub(feed, "fetchFromEndpoint") + .resolves({ spocs: [{ id: "data" }] }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + await feed.loadSpocs(feed.store.dispatch); + + assert.deepEqual( + feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0], + { id: "data", score: 1 } + ); + }); + it("should call personalizationVersionOverride with feature_flags", async () => { + sandbox.stub(feed.cache, "get").returns(Promise.resolve()); + sandbox.stub(feed, "personalizationOverride").returns(); + sandbox + .stub(feed, "fetchFromEndpoint") + .resolves({ settings: { feature_flags: {} }, spocs: [{ id: "data" }] }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + await feed.loadSpocs(feed.store.dispatch); + + assert.calledOnce(feed.personalizationOverride); + }); + it("should return expected data if normalizeSpocsItems returns no spoc data", async () => { + // We don't need this for just this test, we are setting placements manually. + feed.getPlacements.restore(); + Object.defineProperty(feed, "showSponsoredStories", { + get: () => true, + }); + + sandbox.stub(feed.cache, "get").returns(Promise.resolve()); + sandbox + .stub(feed, "fetchFromEndpoint") + .resolves({ placement1: [{ id: "data" }], placement2: [] }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + const fakeComponents = { + components: [ + { placement: { name: "placement1" }, spocs: {} }, + { placement: { name: "placement2" }, spocs: {} }, + ], + }; + feed.updatePlacements(feed.store.dispatch, [fakeComponents]); + + await feed.loadSpocs(feed.store.dispatch); + + assert.deepEqual(feed.store.getState().DiscoveryStream.spocs.data, { + placement1: { + title: "", + context: "", + sponsor: "", + sponsored_by_override: undefined, + items: [{ id: "data", score: 1 }], + }, + placement2: { + title: "", + context: "", + items: [], + }, + }); + }); + it("should use title and context on spoc data", async () => { + // We don't need this for just this test, we are setting placements manually. + feed.getPlacements.restore(); + Object.defineProperty(feed, "showSponsoredStories", { + get: () => true, + }); + sandbox.stub(feed.cache, "get").returns(Promise.resolve()); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ + placement1: { + title: "title", + context: "context", + sponsor: "", + sponsored_by_override: undefined, + items: [{ id: "data" }], + }, + }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + const fakeComponents = { + components: [{ placement: { name: "placement1" }, spocs: {} }], + }; + feed.updatePlacements(feed.store.dispatch, [fakeComponents]); + + await feed.loadSpocs(feed.store.dispatch); + + assert.deepEqual(feed.store.getState().DiscoveryStream.spocs.data, { + placement1: { + title: "title", + context: "context", + sponsor: "", + sponsored_by_override: undefined, + items: [{ id: "data", score: 1 }], + }, + }); + }); + }); + + describe("#normalizeSpocsItems", () => { + it("should return correct data if new data passed in", async () => { + const spocs = { + title: "title", + context: "context", + sponsor: "sponsor", + sponsored_by_override: "override", + items: [{ id: "id" }], + }; + const result = feed.normalizeSpocsItems(spocs); + assert.deepEqual(result, spocs); + }); + it("should return normalized data if new data passed in without title or context", async () => { + const spocs = { + items: [{ id: "id" }], + }; + const result = feed.normalizeSpocsItems(spocs); + assert.deepEqual(result, { + title: "", + context: "", + sponsor: "", + sponsored_by_override: undefined, + items: [{ id: "id" }], + }); + }); + it("should return normalized data if old data passed in", async () => { + const spocs = [{ id: "id" }]; + const result = feed.normalizeSpocsItems(spocs); + assert.deepEqual(result, { + title: "", + context: "", + sponsor: "", + sponsored_by_override: undefined, + items: [{ id: "id" }], + }); + }); + }); + + describe("#showSpocs", () => { + it("should return true from showSpocs if showSponsoredStories is false", async () => { + Object.defineProperty(feed, "showSponsoredStories", { + get: () => false, + }); + Object.defineProperty(feed, "showSponsoredTopsites", { + get: () => true, + }); + assert.isTrue(feed.showSpocs); + }); + it("should return true from showSpocs if showSponsoredTopsites is false", async () => { + Object.defineProperty(feed, "showSponsoredStories", { + get: () => true, + }); + Object.defineProperty(feed, "showSponsoredTopsites", { + get: () => false, + }); + assert.isTrue(feed.showSpocs); + }); + it("should return true from showSpocs if both are true", async () => { + Object.defineProperty(feed, "showSponsoredStories", { + get: () => true, + }); + Object.defineProperty(feed, "showSponsoredTopsites", { + get: () => true, + }); + assert.isTrue(feed.showSpocs); + }); + it("should return false from showSpocs if both are false", async () => { + Object.defineProperty(feed, "showSponsoredStories", { + get: () => false, + }); + Object.defineProperty(feed, "showSponsoredTopsites", { + get: () => false, + }); + assert.isFalse(feed.showSpocs); + }); + }); + + describe("#showSponsoredStories", () => { + it("should return false from showSponsoredStories if user pref showSponsored is false", async () => { + feed.store.getState = () => ({ + Prefs: { values: { showSponsored: false } }, + }); + Object.defineProperty(feed, "config", { + get: () => ({ show_spocs: true }), + }); + + assert.isFalse(feed.showSponsoredStories); + }); + it("should return false from showSponsoredStories if DiscoveryStream pref show_spocs is false", async () => { + feed.store.getState = () => ({ + Prefs: { values: { showSponsored: true } }, + }); + Object.defineProperty(feed, "config", { + get: () => ({ show_spocs: false }), + }); + + assert.isFalse(feed.showSponsoredStories); + }); + it("should return true from showSponsoredStories if both prefs are true", async () => { + feed.store.getState = () => ({ + Prefs: { values: { showSponsored: true } }, + }); + Object.defineProperty(feed, "config", { + get: () => ({ show_spocs: true }), + }); + + assert.isTrue(feed.showSponsoredStories); + }); + }); + + describe("#showSponsoredTopsites", () => { + it("should return false from showSponsoredTopsites if user pref showSponsoredTopSites is false", async () => { + feed.store.getState = () => ({ + Prefs: { values: { showSponsoredTopSites: false } }, + DiscoveryStream: { + spocs: { + placements: [{ name: "sponsored-topsites" }], + }, + }, + }); + assert.isFalse(feed.showSponsoredTopsites); + }); + it("should return true from showSponsoredTopsites if user pref showSponsoredTopSites is true", async () => { + feed.store.getState = () => ({ + Prefs: { values: { showSponsoredTopSites: true } }, + DiscoveryStream: { + spocs: { + placements: [{ name: "sponsored-topsites" }], + }, + }, + }); + assert.isTrue(feed.showSponsoredTopsites); + }); + }); + + describe("#showStories", () => { + it("should return false from showStories if user pref is false", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "feeds.section.topstories": false, + "feeds.system.topstories": true, + }, + }, + }); + assert.isFalse(feed.showStories); + }); + it("should return false from showStories if system pref is false", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "feeds.section.topstories": true, + "feeds.system.topstories": false, + }, + }, + }); + assert.isFalse(feed.showStories); + }); + it("should return true from showStories if both prefs are true", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "feeds.section.topstories": true, + "feeds.system.topstories": true, + }, + }, + }); + assert.isTrue(feed.showStories); + }); + }); + + describe("#showTopsites", () => { + it("should return false from showTopsites if user pref is false", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "feeds.topsites": false, + "feeds.system.topsites": true, + }, + }, + }); + assert.isFalse(feed.showTopsites); + }); + it("should return false from showTopsites if system pref is false", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "feeds.topsites": true, + "feeds.system.topsites": false, + }, + }, + }); + assert.isFalse(feed.showTopsites); + }); + it("should return true from showTopsites if both prefs are true", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "feeds.topsites": true, + "feeds.system.topsites": true, + }, + }, + }); + assert.isTrue(feed.showTopsites); + }); + }); + + describe("#clearSpocs", () => { + let defaultState; + let DiscoveryStream; + let Prefs; + beforeEach(() => { + Object.defineProperty(feed, "config", { + get: () => ({ show_spocs: true }), + }); + DiscoveryStream = { + layout: [], + spocs: { + placements: [{ name: "sponsored-topsites" }], + }, + }; + Prefs = { + values: { + "feeds.section.topstories": true, + "feeds.system.topstories": true, + "feeds.topsites": true, + "feeds.system.topsites": true, + showSponsoredTopSites: true, + showSponsored: true, + }, + }; + defaultState = { + DiscoveryStream, + Prefs, + }; + feed.store.getState = () => defaultState; + }); + it("should not fail with no endpoint", async () => { + sandbox.stub(feed.store, "getState").returns({ + Prefs: { + values: { "discoverystream.endpointSpocsClear": null }, + }, + }); + sandbox.stub(feed, "fetchFromEndpoint").resolves(null); + + await feed.clearSpocs(); + + assert.notCalled(feed.fetchFromEndpoint); + }); + it("should call DELETE with endpoint", async () => { + sandbox.stub(feed.store, "getState").returns({ + Prefs: { + values: { + "discoverystream.endpointSpocsClear": "https://spocs/user", + }, + }, + }); + sandbox.stub(feed, "fetchFromEndpoint").resolves(null); + feed._impressionId = "1234"; + + await feed.clearSpocs(); + + assert.equal( + feed.fetchFromEndpoint.firstCall.args[0], + "https://spocs/user" + ); + assert.equal(feed.fetchFromEndpoint.firstCall.args[1].method, "DELETE"); + assert.equal( + feed.fetchFromEndpoint.firstCall.args[1].body, + '{"pocket_id":"1234"}' + ); + }); + it("should properly call clearSpocs when sponsored content is changed", async () => { + sandbox.stub(feed, "clearSpocs").returns(Promise.resolve()); + //sandbox.stub(feed, "updatePlacements").returns(); + sandbox.stub(feed, "loadSpocs").returns(); + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "showSponsored" }, + }); + + assert.notCalled(feed.clearSpocs); + + Prefs.values.showSponsoredTopSites = false; + Prefs.values.showSponsored = false; + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "showSponsored" }, + }); + + assert.calledOnce(feed.clearSpocs); + }); + it("should call clearSpocs when top stories and top sites is turned off", async () => { + sandbox.stub(feed, "clearSpocs").returns(Promise.resolve()); + Prefs.values["feeds.section.topstories"] = false; + Prefs.values["feeds.topsites"] = false; + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "feeds.section.topstories" }, + }); + + assert.calledOnce(feed.clearSpocs); + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "feeds.topsites" }, + }); + + assert.calledTwice(feed.clearSpocs); + }); + }); + + describe("#rotate", () => { + it("should move seen first story to the back of the response", () => { + const recsExpireTime = 5600; + const feedResponse = { + recommendations: [ + { + id: "first", + }, + { + id: "second", + }, + { + id: "third", + }, + { + id: "fourth", + }, + ], + settings: { + recsExpireTime, + }, + }; + const fakeImpressions = { + first: Date.now() - recsExpireTime * 1000, + third: Date.now(), + }; + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + + const result = feed.rotate( + feedResponse.recommendations, + feedResponse.settings.recsExpireTime + ); + + assert.equal(result[3].id, "first"); + }); + }); + + describe("#reset", () => { + it("should fire all reset based functions", async () => { + sandbox.stub(global.Services.obs, "removeObserver").returns(); + + sandbox.stub(feed, "resetDataPrefs").returns(); + sandbox.stub(feed, "resetCache").returns(Promise.resolve()); + sandbox.stub(feed, "resetState").returns(); + + feed.loaded = true; + + await feed.reset(); + + assert.calledOnce(feed.resetDataPrefs); + assert.calledOnce(feed.resetCache); + assert.calledOnce(feed.resetState); + assert.calledOnce(global.Services.obs.removeObserver); + }); + }); + + describe("#resetCache", () => { + it("should set .layout, .feeds .spocs and .personalization to {}", async () => { + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + await feed.resetCache(); + + assert.callCount(feed.cache.set, 4); + const firstCall = feed.cache.set.getCall(0); + const secondCall = feed.cache.set.getCall(1); + const thirdCall = feed.cache.set.getCall(2); + const fourthCall = feed.cache.set.getCall(3); + assert.deepEqual(firstCall.args, ["layout", {}]); + assert.deepEqual(secondCall.args, ["feeds", {}]); + assert.deepEqual(thirdCall.args, ["spocs", {}]); + assert.deepEqual(fourthCall.args, ["personalization", {}]); + }); + }); + + describe("#scoreItems", () => { + it("should return initial data if spocs are empty", async () => { + const { data: result } = await feed.scoreItems([]); + + assert.equal(result.length, 0); + }); + + it("should sort based on item_score", async () => { + const { data: result } = await feed.scoreItems([ + { id: 2, flight_id: 2, item_score: 0.8 }, + { id: 4, flight_id: 4, item_score: 0.5 }, + { id: 3, flight_id: 3, item_score: 0.7 }, + { id: 1, flight_id: 1, item_score: 0.9 }, + ]); + + assert.deepEqual(result, [ + { id: 1, flight_id: 1, item_score: 0.9, score: 0.9 }, + { id: 2, flight_id: 2, item_score: 0.8, score: 0.8 }, + { id: 3, flight_id: 3, item_score: 0.7, score: 0.7 }, + { id: 4, flight_id: 4, item_score: 0.5, score: 0.5 }, + ]); + }); + + it("should sort based on priority", async () => { + const { data: result } = await feed.scoreItems([ + { id: 6, flight_id: 6, priority: 2, item_score: 0.7 }, + { id: 2, flight_id: 3, priority: 1, item_score: 0.2 }, + { id: 4, flight_id: 4, item_score: 0.6 }, + { id: 5, flight_id: 5, priority: 2, item_score: 0.8 }, + { id: 3, flight_id: 3, item_score: 0.8 }, + { id: 1, flight_id: 1, priority: 1, item_score: 0.3 }, + ]); + + assert.deepEqual(result, [ + { + id: 1, + flight_id: 1, + priority: 1, + score: 0.3, + item_score: 0.3, + }, + { + id: 2, + flight_id: 3, + priority: 1, + score: 0.2, + item_score: 0.2, + }, + { + id: 5, + flight_id: 5, + priority: 2, + score: 0.8, + item_score: 0.8, + }, + { + id: 6, + flight_id: 6, + priority: 2, + score: 0.7, + item_score: 0.7, + }, + { id: 3, flight_id: 3, item_score: 0.8, score: 0.8 }, + { id: 4, flight_id: 4, item_score: 0.6, score: 0.6 }, + ]); + }); + + it("should add a score prop to spocs", async () => { + const { data: result } = await feed.scoreItems([ + { flight_id: 1, item_score: 0.9 }, + ]); + + assert.equal(result[0].score, 0.9); + }); + }); + + describe("#filterBlocked", () => { + it("should return initial data if spocs are empty", () => { + const { data: result } = feed.filterBlocked([]); + + assert.equal(result.length, 0); + }); + it("should return initial data if links are not blocked", () => { + const { data: result } = feed.filterBlocked([ + { url: "https://foo.com" }, + { url: "test.com" }, + ]); + assert.equal(result.length, 2); + }); + it("should return initial recommendations data if links are not blocked", () => { + const { data: result } = feed.filterBlocked([ + { url: "https://foo.com" }, + { url: "test.com" }, + ]); + assert.equal(result.length, 2); + }); + it("filterRecommendations based on blockedlist by passing feed data", () => { + fakeNewTabUtils.blockedLinks.links = [{ url: "https://foo.com" }]; + fakeNewTabUtils.blockedLinks.isBlocked = site => + fakeNewTabUtils.blockedLinks.links[0].url === site.url; + + const result = feed.filterRecommendations({ + lastUpdated: 4, + data: { + recommendations: [{ url: "https://foo.com" }, { url: "test.com" }], + }, + }); + + assert.equal(result.lastUpdated, 4); + assert.lengthOf(result.data.recommendations, 1); + assert.equal(result.data.recommendations[0].url, "test.com"); + assert.notInclude( + result.data.recommendations, + fakeNewTabUtils.blockedLinks.links[0] + ); + }); + }); + + describe("#frequencyCapSpocs", () => { + it("should return filtered out spocs based on frequency caps", () => { + const fakeSpocs = [ + { + id: 1, + flight_id: "seen", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + { + id: 2, + flight_id: "not-seen", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + ]; + const fakeImpressions = { + seen: [Date.now() - 1], + }; + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + + const { data: result, filtered } = feed.frequencyCapSpocs(fakeSpocs); + + assert.equal(result.length, 1); + assert.equal(result[0].flight_id, "not-seen"); + assert.deepEqual(filtered, [fakeSpocs[0]]); + }); + it("should return simple structure and do nothing with no spocs", () => { + const { data: result, filtered } = feed.frequencyCapSpocs([]); + + assert.equal(result.length, 0); + assert.equal(filtered.length, 0); + }); + }); + + describe("#migrateFlightId", () => { + it("should migrate campaign to flight if no flight exists", () => { + const fakeSpocs = [ + { + id: 1, + campaign_id: "campaign", + caps: { + lifetime: 3, + campaign: { + count: 1, + period: 1, + }, + }, + }, + ]; + const { data: result } = feed.migrateFlightId(fakeSpocs); + + assert.deepEqual(result[0], { + id: 1, + flight_id: "campaign", + campaign_id: "campaign", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + campaign: { + count: 1, + period: 1, + }, + }, + }); + }); + it("should not migrate campaign to flight if caps or id don't exist", () => { + const fakeSpocs = [{ id: 1 }]; + const { data: result } = feed.migrateFlightId(fakeSpocs); + + assert.deepEqual(result[0], { id: 1 }); + }); + it("should return simple structure and do nothing with no spocs", () => { + const { data: result } = feed.migrateFlightId([]); + + assert.equal(result.length, 0); + }); + }); + + describe("#isBelowFrequencyCap", () => { + it("should return true if there are no flight impressions", () => { + const fakeImpressions = { + seen: [Date.now() - 1], + }; + const fakeSpoc = { + flight_id: "not-seen", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }; + + const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc); + + assert.isTrue(result); + }); + it("should return true if there are no flight caps", () => { + const fakeImpressions = { + seen: [Date.now() - 1], + }; + const fakeSpoc = { + flight_id: "seen", + caps: { + lifetime: 3, + }, + }; + + const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc); + + assert.isTrue(result); + }); + + it("should return false if lifetime cap is hit", () => { + const fakeImpressions = { + seen: [Date.now() - 1], + }; + const fakeSpoc = { + flight_id: "seen", + caps: { + lifetime: 1, + flight: { + count: 3, + period: 1, + }, + }, + }; + + const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc); + + assert.isFalse(result); + }); + + it("should return false if time based cap is hit", () => { + const fakeImpressions = { + seen: [Date.now() - 1], + }; + const fakeSpoc = { + flight_id: "seen", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }; + + const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc); + + assert.isFalse(result); + }); + }); + + describe("#retryFeed", () => { + it("should retry a feed fetch", async () => { + sandbox.stub(feed, "getComponentFeed").returns(Promise.resolve({})); + sandbox.stub(feed, "filterRecommendations").returns({}); + sandbox.spy(feed.store, "dispatch"); + + await feed.retryFeed({ url: "https://feed.com" }); + + assert.calledOnce(feed.getComponentFeed); + assert.calledOnce(feed.filterRecommendations); + assert.calledOnce(feed.store.dispatch); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + "DISCOVERY_STREAM_FEED_UPDATE" + ); + assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, { + feed: {}, + url: "https://feed.com", + }); + }); + }); + + describe("#recordFlightImpression", () => { + it("should return false if time based cap is hit", () => { + sandbox.stub(feed, "readDataPref").returns({}); + sandbox.stub(feed, "writeDataPref").returns(); + + feed.recordFlightImpression("seen"); + + assert.calledWith(feed.writeDataPref, SPOC_IMPRESSION_TRACKING_PREF, { + seen: [0], + }); + }); + }); + + describe("#recordBlockFlightId", () => { + it("should call writeDataPref with new flight id added", () => { + sandbox.stub(feed, "readDataPref").returns({ 1234: 1 }); + sandbox.stub(feed, "writeDataPref").returns(); + + feed.recordBlockFlightId("5678"); + + assert.calledOnce(feed.readDataPref); + assert.calledWith(feed.writeDataPref, "discoverystream.flight.blocks", { + 1234: 1, + 5678: 1, + }); + }); + }); + + describe("#cleanUpFlightImpressionPref", () => { + it("should remove flight-3 because it is no longer being used", async () => { + const fakeSpocs = { + spocs: { + items: [ + { + flight_id: "flight-1", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + { + flight_id: "flight-2", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + ], + }, + }; + const fakeImpressions = { + "flight-2": [Date.now() - 1], + "flight-3": [Date.now() - 1], + }; + sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]); + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + sandbox.stub(feed, "writeDataPref").returns(); + + feed.cleanUpFlightImpressionPref(fakeSpocs); + + assert.calledWith(feed.writeDataPref, SPOC_IMPRESSION_TRACKING_PREF, { + "flight-2": [-1], + }); + }); + }); + + describe("#recordTopRecImpressions", () => { + it("should add a rec id to the rec impression pref", () => { + sandbox.stub(feed, "readDataPref").returns({}); + sandbox.stub(feed, "writeDataPref"); + + feed.recordTopRecImpressions("rec"); + + assert.calledWith(feed.writeDataPref, REC_IMPRESSION_TRACKING_PREF, { + rec: 0, + }); + }); + it("should not add an impression if it already exists", () => { + sandbox.stub(feed, "readDataPref").returns({ rec: 4 }); + sandbox.stub(feed, "writeDataPref"); + + feed.recordTopRecImpressions("rec"); + + assert.notCalled(feed.writeDataPref); + }); + }); + + describe("#cleanUpTopRecImpressionPref", () => { + it("should remove recs no longer being used", () => { + const newFeeds = { + "https://foo.com": { + data: { + recommendations: [ + { + id: "rec1", + }, + { + id: "rec2", + }, + ], + }, + }, + "https://bar.com": { + data: { + recommendations: [ + { + id: "rec3", + }, + { + id: "rec4", + }, + ], + }, + }, + }; + const fakeImpressions = { + rec2: Date.now() - 1, + rec3: Date.now() - 1, + rec5: Date.now() - 1, + }; + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + sandbox.stub(feed, "writeDataPref").returns(); + + feed.cleanUpTopRecImpressionPref(newFeeds); + + assert.calledWith(feed.writeDataPref, REC_IMPRESSION_TRACKING_PREF, { + rec2: -1, + rec3: -1, + }); + }); + }); + + describe("#writeDataPref", () => { + it("should call Services.prefs.setStringPref", () => { + sandbox.spy(feed.store, "dispatch"); + const fakeImpressions = { + foo: [Date.now() - 1], + bar: [Date.now() - 1], + }; + + feed.writeDataPref(SPOC_IMPRESSION_TRACKING_PREF, fakeImpressions); + + assert.calledWithMatch(feed.store.dispatch, { + data: { + name: SPOC_IMPRESSION_TRACKING_PREF, + value: JSON.stringify(fakeImpressions), + }, + type: at.SET_PREF, + }); + }); + }); + + describe("#addEndpointQuery", () => { + const url = "https://spocs.getpocket.com/spocs"; + + it("should return same url with no query", () => { + const result = feed.addEndpointQuery(url, ""); + assert.equal(result, url); + }); + + it("should add multiple query params to standard url", () => { + const params = "?first=first&second=second"; + const result = feed.addEndpointQuery(url, params); + assert.equal(result, url + params); + }); + + it("should add multiple query params to url with a query already", () => { + const params = "first=first&second=second"; + const initialParams = "?zero=zero"; + const result = feed.addEndpointQuery( + `${url}${initialParams}`, + `?${params}` + ); + assert.equal(result, `${url}${initialParams}&${params}`); + }); + }); + + describe("#readDataPref", () => { + it("should return what's in Services.prefs.getStringPref", () => { + const fakeImpressions = { + foo: [Date.now() - 1], + bar: [Date.now() - 1], + }; + setPref(SPOC_IMPRESSION_TRACKING_PREF, fakeImpressions); + + const result = feed.readDataPref(SPOC_IMPRESSION_TRACKING_PREF); + + assert.deepEqual(result, fakeImpressions); + }); + }); + + describe("#setupPrefs", () => { + it("should call setupPrefs", async () => { + sandbox.spy(feed, "setupPrefs"); + feed.onAction({ + type: at.INIT, + }); + assert.calledOnce(feed.setupPrefs); + }); + it("should dispatch to at.DISCOVERY_STREAM_PREFS_SETUP with proper data", async () => { + sandbox.spy(feed.store, "dispatch"); + globals.set("ExperimentAPI", { + getExperimentMetaData: () => ({ + slug: "experimentId", + branch: { + slug: "branchId", + }, + }), + getRolloutMetaData: () => ({}), + }); + global.Services.prefs.getBoolPref + .withArgs("extensions.pocket.enabled") + .returns(true); + feed.store.getState = () => ({ + Prefs: { + values: { + region: "CA", + pocketConfig: { + recentSavesEnabled: true, + hideDescriptions: false, + hideDescriptionsRegions: "US,CA,GB", + compactImages: true, + imageGradient: true, + newSponsoredLabel: true, + titleLines: "1", + descLines: "1", + readTime: true, + saveToPocketCard: false, + saveToPocketCardRegions: "US,CA,GB", + }, + }, + }, + }); + feed.setupPrefs(); + assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, { + utmSource: "pocket-newtab", + utmCampaign: "experimentId", + utmContent: "branchId", + }); + assert.deepEqual(feed.store.dispatch.secondCall.args[0].data, { + recentSavesEnabled: true, + pocketButtonEnabled: true, + saveToPocketCard: true, + hideDescriptions: true, + compactImages: true, + imageGradient: true, + newSponsoredLabel: true, + titleLines: "1", + descLines: "1", + readTime: true, + }); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_IMPRESSION_STATS", () => { + it("should call recordTopRecImpressions from DISCOVERY_STREAM_IMPRESSION_STATS", async () => { + sandbox.stub(feed, "recordTopRecImpressions").returns(); + await feed.onAction({ + type: at.DISCOVERY_STREAM_IMPRESSION_STATS, + data: { tiles: [{ id: "seen" }] }, + }); + + assert.calledWith(feed.recordTopRecImpressions, "seen"); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_SPOC_IMPRESSION", () => { + beforeEach(() => { + const data = { + spocs: { + items: [ + { + id: 1, + flight_id: "seen", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + { + id: 2, + flight_id: "not-seen", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + ], + }, + }; + sandbox.stub(feed.store, "getState").returns({ + DiscoveryStream: { + spocs: { + data, + }, + }, + }); + }); + + it("should call dispatch to ac.AlsoToPreloaded with filtered spoc data", async () => { + sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]); + Object.defineProperty(feed, "showSpocs", { get: () => true }); + const fakeImpressions = { + seen: [Date.now() - 1], + }; + const result = { + spocs: { + items: [ + { + id: 2, + flight_id: "not-seen", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + ], + }, + }; + sandbox.stub(feed, "recordFlightImpression").returns(); + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + sandbox.spy(feed.store, "dispatch"); + + await feed.onAction({ + type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, + data: { flightId: "seen" }, + }); + + assert.deepEqual( + feed.store.dispatch.secondCall.args[0].data.spocs, + result + ); + }); + it("should not call dispatch to ac.AlsoToPreloaded if spocs were not changed by frequency capping", async () => { + sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]); + Object.defineProperty(feed, "showSpocs", { get: () => true }); + const fakeImpressions = {}; + sandbox.stub(feed, "recordFlightImpression").returns(); + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + sandbox.spy(feed.store, "dispatch"); + + await feed.onAction({ + type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, + data: { flight_id: "seen" }, + }); + + assert.notCalled(feed.store.dispatch); + }); + it("should attempt feq cap on valid spocs with placements on impression", async () => { + sandbox.restore(); + Object.defineProperty(feed, "showSpocs", { get: () => true }); + const fakeImpressions = {}; + sandbox.stub(feed, "recordFlightImpression").returns(); + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + sandbox.spy(feed.store, "dispatch"); + sandbox.spy(feed, "frequencyCapSpocs"); + + const data = { + spocs: { + items: [ + { + id: 2, + flight_id: "seen-2", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + ], + }, + }; + sandbox.stub(feed.store, "getState").returns({ + DiscoveryStream: { + spocs: { + data, + placements: [{ name: "spocs" }, { name: "notSpocs" }], + }, + }, + }); + + await feed.onAction({ + type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, + data: { flight_id: "doesn't matter" }, + }); + + assert.calledOnce(feed.frequencyCapSpocs); + assert.calledWith(feed.frequencyCapSpocs, data.spocs.items); + }); + }); + + describe("#onAction: PLACES_LINK_BLOCKED", () => { + beforeEach(() => { + const data = { + spocs: { + items: [ + { + id: 1, + flight_id: "foo", + url: "foo.com", + }, + { + id: 2, + flight_id: "bar", + url: "bar.com", + }, + ], + }, + }; + sandbox.stub(feed.store, "getState").returns({ + DiscoveryStream: { + spocs: { + data, + placements: [{ name: "spocs" }], + }, + }, + }); + }); + + it("should call dispatch if found a blocked spoc", async () => { + Object.defineProperty(feed, "showSpocs", { get: () => true }); + + sandbox.spy(feed.store, "dispatch"); + + await feed.onAction({ + type: at.PLACES_LINK_BLOCKED, + data: { url: "foo.com" }, + }); + + assert.deepEqual( + feed.store.dispatch.firstCall.args[0].data.url, + "foo.com" + ); + }); + it("should dispatch once if the blocked is not a SPOC", async () => { + Object.defineProperty(feed, "showSpocs", { get: () => true }); + sandbox.spy(feed.store, "dispatch"); + + await feed.onAction({ + type: at.PLACES_LINK_BLOCKED, + data: { url: "not_a_spoc.com" }, + }); + + assert.calledOnce(feed.store.dispatch); + assert.deepEqual( + feed.store.dispatch.firstCall.args[0].data.url, + "not_a_spoc.com" + ); + }); + it("should dispatch a DISCOVERY_STREAM_SPOC_BLOCKED for a blocked spoc", async () => { + Object.defineProperty(feed, "showSpocs", { get: () => true }); + sandbox.spy(feed.store, "dispatch"); + + await feed.onAction({ + type: at.PLACES_LINK_BLOCKED, + data: { url: "foo.com" }, + }); + + assert.equal( + feed.store.dispatch.secondCall.args[0].type, + "DISCOVERY_STREAM_SPOC_BLOCKED" + ); + }); + }); + + describe("#onAction: BLOCK_URL", () => { + it("should call recordBlockFlightId whith BLOCK_URL", async () => { + sandbox.stub(feed, "recordBlockFlightId").returns(); + + await feed.onAction({ + type: at.BLOCK_URL, + data: [ + { + flight_id: "1234", + }, + ], + }); + + assert.calledWith(feed.recordBlockFlightId, "1234"); + }); + }); + + describe("#onAction: INIT", () => { + it("should be .loaded=false before initialization", () => { + assert.isFalse(feed.loaded); + }); + it("should load data and set .loaded=true if config.enabled is true", async () => { + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + setPref(CONFIG_PREF_NAME, { enabled: true }); + sandbox.stub(feed, "loadLayout").returns(Promise.resolve()); + + await feed.onAction({ type: at.INIT }); + + assert.calledOnce(feed.loadLayout); + assert.isTrue(feed.loaded); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_CONFIG_SET_VALUE", async () => { + it("should add the new value to the pref without changing the existing values", async () => { + sandbox.spy(feed.store, "dispatch"); + setPref(CONFIG_PREF_NAME, { enabled: true, other: "value" }); + + await feed.onAction({ + type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE, + data: { name: "layout_endpoint", value: "foo.com" }, + }); + + assert.calledWithMatch(feed.store.dispatch, { + data: { + name: CONFIG_PREF_NAME, + value: JSON.stringify({ + enabled: true, + other: "value", + layout_endpoint: "foo.com", + }), + }, + type: at.SET_PREF, + }); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_POCKET_STATE_INIT", async () => { + it("should call setupPocketState", async () => { + sandbox.spy(feed, "setupPocketState"); + feed.onAction({ + type: at.DISCOVERY_STREAM_POCKET_STATE_INIT, + meta: { fromTarget: {} }, + }); + assert.calledOnce(feed.setupPocketState); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_CONFIG_RESET", async () => { + it("should call configReset", async () => { + sandbox.spy(feed, "configReset"); + feed.onAction({ + type: at.DISCOVERY_STREAM_CONFIG_RESET, + }); + assert.calledOnce(feed.configReset); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", async () => { + it("Should dispatch CLEAR_PREF with pref name", async () => { + sandbox.spy(feed.store, "dispatch"); + await feed.onAction({ + type: at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS, + }); + + assert.calledWithMatch(feed.store.dispatch, { + data: { + name: CONFIG_PREF_NAME, + }, + type: at.CLEAR_PREF, + }); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_RETRY_FEED", async () => { + it("should call retryFeed", async () => { + sandbox.spy(feed, "retryFeed"); + feed.onAction({ + type: at.DISCOVERY_STREAM_RETRY_FEED, + data: { feed: { url: "https://feed.com" } }, + }); + assert.calledOnce(feed.retryFeed); + assert.calledWith(feed.retryFeed, { url: "https://feed.com" }); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_CONFIG_CHANGE", () => { + it("should call this.loadLayout if config.enabled changes to true ", async () => { + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + // First initialize + await feed.onAction({ type: at.INIT }); + assert.isFalse(feed.loaded); + + // force clear cached pref value + feed._prefCache = {}; + setPref(CONFIG_PREF_NAME, { enabled: true }); + + sandbox.stub(feed, "resetCache").returns(Promise.resolve()); + sandbox.stub(feed, "loadLayout").returns(Promise.resolve()); + await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE }); + + assert.calledOnce(feed.loadLayout); + assert.calledOnce(feed.resetCache); + assert.isTrue(feed.loaded); + }); + it("should clear the cache if a config change happens and config.enabled is true", async () => { + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + // force clear cached pref value + feed._prefCache = {}; + setPref(CONFIG_PREF_NAME, { enabled: true }); + + sandbox.stub(feed, "resetCache").returns(Promise.resolve()); + await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE }); + + assert.calledOnce(feed.resetCache); + }); + it("should dispatch DISCOVERY_STREAM_LAYOUT_RESET from DISCOVERY_STREAM_CONFIG_CHANGE", async () => { + sandbox.stub(feed, "resetDataPrefs"); + sandbox.stub(feed, "resetCache").resolves(); + sandbox.stub(feed, "enable").resolves(); + setPref(CONFIG_PREF_NAME, { enabled: true }); + sandbox.spy(feed.store, "dispatch"); + + await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE }); + + assert.calledWithMatch(feed.store.dispatch, { + type: at.DISCOVERY_STREAM_LAYOUT_RESET, + }); + }); + it("should not call this.loadLayout if config.enabled changes to false", async () => { + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + // force clear cached pref value + feed._prefCache = {}; + setPref(CONFIG_PREF_NAME, { enabled: true }); + + await feed.onAction({ type: at.INIT }); + assert.isTrue(feed.loaded); + + feed._prefCache = {}; + setPref(CONFIG_PREF_NAME, { enabled: false }); + sandbox.stub(feed, "resetCache").returns(Promise.resolve()); + sandbox.stub(feed, "loadLayout").returns(Promise.resolve()); + await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE }); + + assert.notCalled(feed.loadLayout); + assert.calledOnce(feed.resetCache); + assert.isFalse(feed.loaded); + }); + }); + + describe("#onAction: UNINIT", () => { + it("should reset pref cache", async () => { + feed._prefCache = { cached: "value" }; + + await feed.onAction({ type: at.UNINIT }); + + assert.deepEqual(feed._prefCache, {}); + }); + }); + + describe("#onAction: PREF_CHANGED", () => { + it("should update state.DiscoveryStream.config when the pref changes", async () => { + setPref(CONFIG_PREF_NAME, { + enabled: true, + show_spocs: false, + layout_endpoint: "foo", + }); + + assert.deepEqual(feed.store.getState().DiscoveryStream.config, { + enabled: true, + show_spocs: false, + layout_endpoint: "foo", + }); + }); + it("should fire loadSpocs is showSponsored pref changes", async () => { + sandbox.stub(feed, "loadSpocs").returns(Promise.resolve()); + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "showSponsored" }, + }); + + assert.calledOnce(feed.loadSpocs); + }); + it("should fire onPrefChange when pocketConfig pref changes", async () => { + sandbox.stub(feed, "onPrefChange").returns(Promise.resolve()); + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "pocketConfig", value: false }, + }); + + assert.calledOnce(feed.onPrefChange); + }); + it("should fire onCollectionsChanged when collections pref changes", async () => { + sandbox.stub(feed, "onCollectionsChanged").returns(Promise.resolve()); + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "discoverystream.sponsored-collections.enabled" }, + }); + + assert.calledOnce(feed.onCollectionsChanged); + }); + it("should re enable stories when top stories is turned on", async () => { + sandbox.stub(feed, "refreshAll").returns(Promise.resolve()); + feed.loaded = true; + setPref(CONFIG_PREF_NAME, { + enabled: true, + }); + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "feeds.section.topstories", value: true }, + }); + + assert.calledOnce(feed.refreshAll); + }); + }); + + describe("#onAction: SYSTEM_TICK", () => { + it("should not refresh if DiscoveryStream has not been loaded", async () => { + sandbox.stub(feed, "refreshAll").resolves(); + setPref(CONFIG_PREF_NAME, { enabled: true }); + + await feed.onAction({ type: at.SYSTEM_TICK }); + assert.notCalled(feed.refreshAll); + }); + + it("should not refresh if no caches are expired", async () => { + sandbox.stub(feed.cache, "set").resolves(); + setPref(CONFIG_PREF_NAME, { enabled: true }); + + await feed.onAction({ type: at.INIT }); + + sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(false); + sandbox.stub(feed, "refreshAll").resolves(); + + await feed.onAction({ type: at.SYSTEM_TICK }); + assert.notCalled(feed.refreshAll); + }); + + it("should refresh if DiscoveryStream has been loaded at least once and a cache has expired", async () => { + sandbox.stub(feed.cache, "set").resolves(); + setPref(CONFIG_PREF_NAME, { enabled: true }); + + await feed.onAction({ type: at.INIT }); + + sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(true); + sandbox.stub(feed, "refreshAll").resolves(); + + await feed.onAction({ type: at.SYSTEM_TICK }); + assert.calledOnce(feed.refreshAll); + }); + + it("should refresh and not update open tabs if DiscoveryStream has been loaded at least once", async () => { + sandbox.stub(feed.cache, "set").resolves(); + setPref(CONFIG_PREF_NAME, { enabled: true }); + + await feed.onAction({ type: at.INIT }); + + sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(true); + sandbox.stub(feed, "refreshAll").resolves(); + + await feed.onAction({ type: at.SYSTEM_TICK }); + assert.calledWith(feed.refreshAll, { updateOpenTabs: false }); + }); + }); + + describe("#onCollectionsChanged", () => { + it("should call loadLayout when Pocket config changes", async () => { + sandbox.stub(feed, "loadLayout").callsFake(dispatch => dispatch("foo")); + sandbox.stub(feed.store, "dispatch"); + await feed.onCollectionsChanged(); + assert.calledOnce(feed.loadLayout); + assert.calledWith(feed.store.dispatch, ac.AlsoToPreloaded("foo")); + }); + }); + + describe("#onPrefChange", () => { + it("should call loadLayout when Pocket config changes", async () => { + sandbox.stub(feed, "loadLayout"); + feed._prefCache.config = { + enabled: true, + }; + await feed.onPrefChange(); + assert.calledOnce(feed.loadLayout); + }); + }); + + describe("#onAction: PREF_SHOW_SPONSORED", () => { + it("should call loadSpocs when preference changes", async () => { + sandbox.stub(feed, "loadSpocs").resolves(); + sandbox.stub(feed.store, "dispatch"); + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "showSponsored" }, + }); + + assert.calledOnce(feed.loadSpocs); + const [dispatchFn] = feed.loadSpocs.firstCall.args; + dispatchFn({}); + assert.calledWith(feed.store.dispatch, ac.BroadcastToContent({})); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_DEV_IDLE_DAILY", () => { + it("should trigger idle-daily observer", async () => { + sandbox.stub(global.Services.obs, "notifyObservers").returns(); + await feed.onAction({ + type: at.DISCOVERY_STREAM_DEV_IDLE_DAILY, + }); + assert.calledWith( + global.Services.obs.notifyObservers, + null, + "idle-daily" + ); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_DEV_SYNC_RS", () => { + it("should fire remote settings pollChanges", async () => { + sandbox.stub(global.RemoteSettings, "pollChanges").returns(); + await feed.onAction({ + type: at.DISCOVERY_STREAM_DEV_SYNC_RS, + }); + assert.calledOnce(global.RemoteSettings.pollChanges); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_DEV_SYSTEM_TICK", () => { + it("should refresh if DiscoveryStream has been loaded at least once and a cache has expired", async () => { + sandbox.stub(feed.cache, "set").resolves(); + setPref(CONFIG_PREF_NAME, { enabled: true }); + + await feed.onAction({ type: at.INIT }); + + sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(true); + sandbox.stub(feed, "refreshAll").resolves(); + + await feed.onAction({ type: at.DISCOVERY_STREAM_DEV_SYSTEM_TICK }); + assert.calledOnce(feed.refreshAll); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_DEV_EXPIRE_CACHE", () => { + it("should fire resetCache", async () => { + sandbox.stub(feed, "resetContentCache").returns(); + await feed.onAction({ + type: at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE, + }); + assert.calledOnce(feed.resetContentCache); + }); + }); + + describe("#spocsCacheUpdateTime", () => { + it("should call setupSpocsCacheUpdateTime", () => { + const defaultCacheTime = 30 * 60 * 1000; + sandbox.spy(feed, "setupSpocsCacheUpdateTime"); + const cacheTime = feed.spocsCacheUpdateTime; + assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime); + assert.equal(cacheTime, defaultCacheTime); + assert.calledOnce(feed.setupSpocsCacheUpdateTime); + }); + it("should return _spocsCacheUpdateTime", () => { + sandbox.spy(feed, "setupSpocsCacheUpdateTime"); + const testCacheTime = 123; + feed._spocsCacheUpdateTime = testCacheTime; + const cacheTime = feed.spocsCacheUpdateTime; + // Ensure _spocsCacheUpdateTime was not changed. + assert.equal(feed._spocsCacheUpdateTime, testCacheTime); + assert.equal(cacheTime, testCacheTime); + assert.notCalled(feed.setupSpocsCacheUpdateTime); + }); + }); + + describe("#setupSpocsCacheUpdateTime", () => { + it("should set _spocsCacheUpdateTime with default value", () => { + const defaultCacheTime = 30 * 60 * 1000; + feed.setupSpocsCacheUpdateTime(); + assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime); + }); + it("should set _spocsCacheUpdateTime with min", () => { + const defaultCacheTime = 30 * 60 * 1000; + feed.store.getState = () => ({ + Prefs: { + values: { + pocketConfig: { + spocsCacheTimeout: 1, + }, + }, + }, + }); + feed.setupSpocsCacheUpdateTime(); + assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime); + }); + it("should set _spocsCacheUpdateTime with max", () => { + const defaultCacheTime = 30 * 60 * 1000; + feed.store.getState = () => ({ + Prefs: { + values: { + pocketConfig: { + spocsCacheTimeout: 31, + }, + }, + }, + }); + feed.setupSpocsCacheUpdateTime(); + assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime); + }); + it("should set _spocsCacheUpdateTime with spocsCacheTimeout", () => { + feed.store.getState = () => ({ + Prefs: { + values: { + pocketConfig: { + spocsCacheTimeout: 20, + }, + }, + }, + }); + feed.setupSpocsCacheUpdateTime(); + assert.equal(feed._spocsCacheUpdateTime, 20 * 60 * 1000); + }); + }); + + describe("#isExpired", () => { + it("should throw if the key is not valid", () => { + assert.throws(() => { + feed.isExpired({}, "foo"); + }); + }); + it("should return false for layout on startup for content under 1 week", () => { + const layout = { lastUpdated: Date.now() }; + const result = feed.isExpired({ + cachedData: { layout }, + key: "layout", + isStartup: true, + }); + + assert.isFalse(result); + }); + it("should return true for layout for isStartup=false after 30 mins", () => { + const layout = { lastUpdated: Date.now() }; + clock.tick(THIRTY_MINUTES + 1); + const result = feed.isExpired({ cachedData: { layout }, key: "layout" }); + + assert.isTrue(result); + }); + it("should return true for layout on startup for content over 1 week", () => { + const layout = { lastUpdated: Date.now() }; + clock.tick(ONE_WEEK + 1); + const result = feed.isExpired({ + cachedData: { layout }, + key: "layout", + isStartup: true, + }); + + assert.isTrue(result); + }); + it("should return false for hardcoded layout on startup for content over 1 week", () => { + feed._prefCache.config = { + hardcoded_layout: true, + }; + const layout = { lastUpdated: Date.now() }; + clock.tick(ONE_WEEK + 1); + const result = feed.isExpired({ + cachedData: { layout }, + key: "layout", + isStartup: true, + }); + + assert.isFalse(result); + }); + }); + + describe("#checkIfAnyCacheExpired", () => { + let cache; + beforeEach(() => { + cache = { + layout: { lastUpdated: Date.now() }, + feeds: { "foo.com": { lastUpdated: Date.now() } }, + spocs: { lastUpdated: Date.now() }, + }; + Object.defineProperty(feed, "showSpocs", { get: () => true }); + sandbox.stub(feed.cache, "get").resolves(cache); + }); + + it("should return false if nothing in the cache is expired", async () => { + const result = await feed.checkIfAnyCacheExpired(); + assert.isFalse(result); + }); + + it("should return true if .layout is missing", async () => { + delete cache.layout; + assert.isTrue(await feed.checkIfAnyCacheExpired()); + }); + it("should return true if .layout is expired", async () => { + clock.tick(THIRTY_MINUTES + 1); + // Update other caches we aren't testing + cache.feeds["foo.com"].lastUpdate = Date.now(); + cache.spocs.lastUpdate = Date.now(); + + assert.isTrue(await feed.checkIfAnyCacheExpired()); + }); + + it("should return true if .spocs is missing", async () => { + delete cache.spocs; + assert.isTrue(await feed.checkIfAnyCacheExpired()); + }); + it("should return true if .spocs is expired", async () => { + clock.tick(THIRTY_MINUTES + 1); + // Update other caches we aren't testing + cache.layout.lastUpdated = Date.now(); + cache.feeds["foo.com"].lastUpdate = Date.now(); + + assert.isTrue(await feed.checkIfAnyCacheExpired()); + }); + + it("should return true if .feeds is missing", async () => { + delete cache.feeds; + assert.isTrue(await feed.checkIfAnyCacheExpired()); + }); + it("should return true if data for .feeds[url] is missing", async () => { + cache.feeds["foo.com"] = null; + assert.isTrue(await feed.checkIfAnyCacheExpired()); + }); + it("should return true if data for .feeds[url] is expired", async () => { + clock.tick(THIRTY_MINUTES + 1); + // Update other caches we aren't testing + cache.layout.lastUpdated = Date.now(); + cache.spocs.lastUpdate = Date.now(); + assert.isTrue(await feed.checkIfAnyCacheExpired()); + }); + }); + + describe("#refreshAll", () => { + beforeEach(() => { + sandbox.stub(feed, "loadLayout").resolves(); + sandbox.stub(feed, "loadComponentFeeds").resolves(); + sandbox.stub(feed, "loadSpocs").resolves(); + sandbox.spy(feed.store, "dispatch"); + Object.defineProperty(feed, "showSpocs", { get: () => true }); + }); + + it("should call layout, component, spocs update and telemetry reporting functions", async () => { + await feed.refreshAll(); + + assert.calledOnce(feed.loadLayout); + assert.calledOnce(feed.loadComponentFeeds); + assert.calledOnce(feed.loadSpocs); + }); + it("should pass in dispatch wrapped with broadcast if options.updateOpenTabs is true", async () => { + await feed.refreshAll({ updateOpenTabs: true }); + [feed.loadLayout, feed.loadComponentFeeds, feed.loadSpocs].forEach(fn => { + assert.calledOnce(fn); + const result = fn.firstCall.args[0]({ type: "FOO" }); + assert.isTrue(au.isBroadcastToContent(result)); + }); + }); + it("should pass in dispatch with regular actions if options.updateOpenTabs is false", async () => { + await feed.refreshAll({ updateOpenTabs: false }); + [feed.loadLayout, feed.loadComponentFeeds, feed.loadSpocs].forEach(fn => { + assert.calledOnce(fn); + const result = fn.firstCall.args[0]({ type: "FOO" }); + assert.deepEqual(result, { type: "FOO" }); + }); + }); + it("should set loaded to true if loadSpocs and loadComponentFeeds fails", async () => { + feed.loadComponentFeeds.rejects("loadComponentFeeds error"); + feed.loadSpocs.rejects("loadSpocs error"); + + await feed.enable(); + + assert.isTrue(feed.loaded); + }); + it("should call loadComponentFeeds and loadSpocs in Promise.all", async () => { + sandbox.stub(global.Promise, "all").resolves(); + + await feed.refreshAll(); + + assert.calledOnce(global.Promise.all); + const { args } = global.Promise.all.firstCall; + assert.equal(args[0].length, 2); + }); + describe("test startup cache behaviour", () => { + beforeEach(() => { + feed._maybeUpdateCachedData.restore(); + sandbox.stub(feed.cache, "set").resolves(); + }); + it("should refresh layout on startup if it was served from cache", async () => { + feed.loadLayout.restore(); + sandbox + .stub(feed.cache, "get") + .resolves({ layout: { lastUpdated: Date.now(), layout: {} } }); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ layout: {} }); + clock.tick(THIRTY_MINUTES + 1); + + await feed.refreshAll({ isStartup: true }); + + assert.calledOnce(feed.fetchFromEndpoint); + // Once from cache, once to update the store + assert.calledTwice(feed.store.dispatch); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.DISCOVERY_STREAM_LAYOUT_UPDATE + ); + }); + it("should not refresh layout on startup if it is under THIRTY_MINUTES", async () => { + feed.loadLayout.restore(); + sandbox + .stub(feed.cache, "get") + .resolves({ layout: { lastUpdated: Date.now(), layout: {} } }); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ layout: {} }); + + await feed.refreshAll({ isStartup: true }); + + assert.notCalled(feed.fetchFromEndpoint); + }); + it("should refresh spocs on startup if it was served from cache", async () => { + feed.loadSpocs.restore(); + sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]); + sandbox + .stub(feed.cache, "get") + .resolves({ spocs: { lastUpdated: Date.now() } }); + sandbox.stub(feed, "fetchFromEndpoint").resolves("data"); + clock.tick(THIRTY_MINUTES + 1); + + await feed.refreshAll({ isStartup: true }); + + assert.calledOnce(feed.fetchFromEndpoint); + // Once from cache, once to update the store + assert.calledTwice(feed.store.dispatch); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.DISCOVERY_STREAM_SPOCS_UPDATE + ); + }); + it("should not refresh spocs on startup if it is under THIRTY_MINUTES", async () => { + feed.loadSpocs.restore(); + sandbox + .stub(feed.cache, "get") + .resolves({ spocs: { lastUpdated: Date.now() } }); + sandbox.stub(feed, "fetchFromEndpoint").resolves("data"); + + await feed.refreshAll({ isStartup: true }); + + assert.notCalled(feed.fetchFromEndpoint); + }); + it("should refresh feeds on startup if it was served from cache", async () => { + feed.loadComponentFeeds.restore(); + + const fakeComponents = { components: [{ feed: { url: "foo.com" } }] }; + const fakeLayout = [fakeComponents]; + const fakeDiscoveryStream = { + DiscoveryStream: { + layout: fakeLayout, + }, + Prefs: { + values: { + "feeds.section.topstories": true, + "feeds.system.topstories": true, + }, + }, + }; + sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream); + sandbox.stub(feed, "rotate").callsFake(val => val); + sandbox + .stub(feed, "scoreItems") + .callsFake(val => ({ data: val, filtered: [] })); + sandbox.stub(feed, "cleanUpTopRecImpressionPref").callsFake(val => val); + + const fakeCache = { + feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } }, + }; + sandbox.stub(feed.cache, "get").resolves(fakeCache); + clock.tick(THIRTY_MINUTES + 1); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ + recommendations: "data", + settings: { + recsExpireTime: 1, + }, + }); + + await feed.refreshAll({ isStartup: true }); + + assert.calledOnce(feed.fetchFromEndpoint); + // Once from cache, once to update the feed, once to update that all feeds are done. + assert.calledThrice(feed.store.dispatch); + assert.equal( + feed.store.dispatch.secondCall.args[0].type, + at.DISCOVERY_STREAM_FEEDS_UPDATE + ); + }); + }); + }); + + describe("#scoreFeeds", () => { + it("should score feeds and set cache, and dispatch", async () => { + sandbox.stub(feed.cache, "set").resolves(); + sandbox.spy(feed.store, "dispatch"); + const recsExpireTime = 5600; + const fakeImpressions = { + first: Date.now() - recsExpireTime * 1000, + third: Date.now(), + }; + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + const fakeFeeds = { + data: { + "https://foo.com": { + data: { + recommendations: [ + { + id: "first", + item_score: 0.7, + }, + { + id: "second", + item_score: 0.6, + }, + ], + settings: { + recsExpireTime, + }, + }, + }, + "https://bar.com": { + data: { + recommendations: [ + { + id: "third", + item_score: 0.4, + }, + { + id: "fourth", + item_score: 0.6, + }, + { + id: "fifth", + item_score: 0.8, + }, + ], + settings: { + recsExpireTime, + }, + }, + }, + }, + }; + const feedsTestResult = { + "https://foo.com": { + data: { + recommendations: [ + { + id: "second", + item_score: 0.6, + score: 0.6, + }, + { + id: "first", + item_score: 0.7, + score: 0.7, + }, + ], + settings: { + recsExpireTime, + }, + }, + }, + "https://bar.com": { + data: { + recommendations: [ + { + id: "fifth", + item_score: 0.8, + score: 0.8, + }, + { + id: "fourth", + item_score: 0.6, + score: 0.6, + }, + { + id: "third", + item_score: 0.4, + score: 0.4, + }, + ], + settings: { + recsExpireTime, + }, + }, + }, + }; + + await feed.scoreFeeds(fakeFeeds); + + assert.calledWith(feed.cache.set, "feeds", feedsTestResult); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.DISCOVERY_STREAM_FEED_UPDATE + ); + assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, { + url: "https://foo.com", + feed: feedsTestResult["https://foo.com"], + }); + assert.equal( + feed.store.dispatch.secondCall.args[0].type, + at.DISCOVERY_STREAM_FEED_UPDATE + ); + assert.deepEqual(feed.store.dispatch.secondCall.args[0].data, { + url: "https://bar.com", + feed: feedsTestResult["https://bar.com"], + }); + }); + }); + + describe("#scoreSpocs", () => { + it("should score spocs and set cache, dispatch", async () => { + sandbox.stub(feed.cache, "set").resolves(); + sandbox.spy(feed.store, "dispatch"); + const fakeDiscoveryStream = { + Prefs: { + values: { + "discoverystream.spocs.personalized": true, + "discoverystream.recs.personalized": false, + }, + }, + DiscoveryStream: { + spocs: { + placements: [ + { name: "placement1" }, + { name: "placement2" }, + { name: "placement3" }, + ], + }, + }, + }; + sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream); + const fakeSpocs = { + lastUpdated: 1234, + data: { + placement1: { + items: [ + { + item_score: 0.6, + }, + { + item_score: 0.4, + }, + { + item_score: 0.8, + }, + ], + }, + placement2: { + items: [ + { + item_score: 0.6, + }, + { + item_score: 0.8, + }, + ], + }, + placement3: { items: [] }, + }, + }; + + await feed.scoreSpocs(fakeSpocs); + + const spocsTestResult = { + lastUpdated: 1234, + spocs: { + placement1: { + items: [ + { + score: 0.8, + item_score: 0.8, + }, + { + score: 0.6, + item_score: 0.6, + }, + { + score: 0.4, + item_score: 0.4, + }, + ], + }, + placement2: { + items: [ + { + score: 0.8, + item_score: 0.8, + }, + { + score: 0.6, + item_score: 0.6, + }, + ], + }, + placement3: { items: [] }, + }, + }; + assert.calledWith(feed.cache.set, "spocs", spocsTestResult); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.DISCOVERY_STREAM_SPOCS_UPDATE + ); + assert.deepEqual( + feed.store.dispatch.firstCall.args[0].data, + spocsTestResult + ); + }); + }); + + describe("#scoreContent", () => { + it("should call scoreFeeds and scoreSpocs if loaded", async () => { + const fakeDiscoveryStream = { + Prefs: { + values: { + pocketConfig: { + recsPersonalized: true, + spocsPersonalized: true, + }, + "discoverystream.personalization.enabled": true, + "feeds.section.topstories": true, + "feeds.system.topstories": true, + }, + }, + DiscoveryStream: { + feeds: { loaded: false }, + spocs: { loaded: false }, + }, + }; + + sandbox.stub(feed, "scoreFeeds").resolves(); + sandbox.stub(feed, "scoreSpocs").resolves(); + sandbox.stub(feed, "refreshContent").resolves(); + sandbox.stub(feed, "loadPersonalizationScoresCache").resolves(); + sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream); + sandbox.stub(feed, "_checkExpirationPerComponent").resolves({ + feeds: true, + spocs: true, + }); + + await feed.refreshAll(); + + assert.notCalled(feed.scoreFeeds); + assert.notCalled(feed.scoreSpocs); + + fakeDiscoveryStream.DiscoveryStream.feeds.loaded = true; + fakeDiscoveryStream.DiscoveryStream.spocs.loaded = true; + + await feed.refreshAll(); + + assert.calledOnce(feed.scoreFeeds); + assert.calledOnce(feed.scoreSpocs); + }); + }); + + describe("#loadPersonalizationScoresCache", () => { + it("should create a personalization provider from cached scores", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + pocketConfig: { + recsPersonalized: true, + spocsPersonalized: true, + }, + "discoverystream.personalization.enabled": true, + "feeds.section.topstories": true, + "feeds.system.topstories": true, + }, + }, + }); + const fakeCache = { + personalization: { + scores: 123, + _timestamp: 456, + }, + }; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + + await feed.loadPersonalizationScoresCache(); + + assert.equal(feed.personalizationLastUpdated, 456); + }); + }); + + describe("#observe", () => { + it("should call updatePersonalizationScores on idle daily", async () => { + sandbox.stub(feed, "updatePersonalizationScores").returns(); + feed.observe(null, "idle-daily"); + assert.calledOnce(feed.updatePersonalizationScores); + }); + it("should call configReset on Pocket button pref change", async () => { + sandbox.stub(feed, "configReset").returns(); + feed.observe(null, "nsPref:changed", "extensions.pocket.enabled"); + assert.calledOnce(feed.configReset); + }); + }); + + describe("#updatePersonalizationScores", () => { + it("should update recommendationProvider on updatePersonalizationScores", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + pocketConfig: { + recsPersonalized: true, + spocsPersonalized: true, + }, + "discoverystream.personalization.enabled": true, + "feeds.section.topstories": true, + "feeds.system.topstories": true, + }, + }, + }); + sandbox.stub(feed.recommendationProvider, "init").returns(); + + await feed.updatePersonalizationScores(); + + assert.deepEqual(feed.recommendationProvider.provider.getScores(), { + interestConfig: undefined, + interestVector: undefined, + }); + }); + it("should not update recommendationProvider on updatePersonalizationScores", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.spocs.personalized": true, + "discoverystream.recs.personalized": true, + "discoverystream.personalization.enabled": false, + }, + }, + }); + await feed.updatePersonalizationScores(); + + assert.isTrue(!feed.recommendationProvider.provider); + }); + }); + describe("#scoreItem", () => { + it("should call calculateItemRelevanceScore with recommendationProvider with initial score", async () => { + const item = { + item_score: 0.6, + }; + feed.store.getState = () => ({ + Prefs: { + values: { + pocketConfig: { + recsPersonalized: true, + spocsPersonalized: true, + }, + "discoverystream.personalization.enabled": true, + "feeds.section.topstories": true, + "feeds.system.topstories": true, + }, + }, + }); + feed.recommendationProvider.calculateItemRelevanceScore = sandbox + .stub() + .returns(); + const result = await feed.scoreItem(item, true); + assert.calledOnce( + feed.recommendationProvider.calculateItemRelevanceScore + ); + assert.equal(result.score, 0.6); + }); + it("should fallback to score 1 without an initial score", async () => { + const item = {}; + feed.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.spocs.personalized": true, + "discoverystream.recs.personalized": true, + "discoverystream.personalization.enabled": true, + }, + }, + }); + feed.recommendationProvider.calculateItemRelevanceScore = sandbox + .stub() + .returns(); + const result = await feed.scoreItem(item, true); + assert.equal(result.score, 1); + }); + }); + describe("new proxy feed", () => { + beforeEach(() => { + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + pocketConfig: { regionBffConfig: "DE" }, + }, + }, + }); + sandbox.stub(global.Region, "home").get(() => "DE"); + globals.set("NimbusFeatures", { + saveToPocket: { + getVariable: sandbox.stub(), + }, + }); + global.NimbusFeatures.saveToPocket.getVariable + .withArgs("bffApi") + .returns("bffApi"); + global.NimbusFeatures.saveToPocket.getVariable + .withArgs("oAuthConsumerKeyBff") + .returns("oAuthConsumerKeyBff"); + }); + it("should return true with isBff", async () => { + assert.isUndefined(feed._isBff); + assert.isTrue(feed.isBff); + assert.isTrue(feed._isBff); + }); + it("should update to new feed url", async () => { + await feed.loadLayout(feed.store.dispatch); + const { layout } = feed.store.getState().DiscoveryStream; + assert.equal( + layout[0].components[2].feed.url, + "https://bffApi/desktop/v1/recommendations?locale=$locale®ion=$region&count=30" + ); + }); + it("should fetch proper data from getComponentFeed", async () => { + const fakeCache = {}; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed, "rotate").callsFake(val => val); + sandbox + .stub(feed, "scoreItems") + .callsFake(val => ({ data: val, filtered: [] })); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ + data: [ + { + tileId: 1234, + url: "url", + title: "title", + excerpt: "excerpt", + publisher: "publisher", + imageUrl: "imageUrl", + }, + ], + }); + + const feedData = await feed.getComponentFeed("url"); + assert.deepEqual(feedData, { + lastUpdated: 0, + data: { + settings: {}, + recommendations: [ + { + id: 1234, + url: "url", + title: "title", + excerpt: "excerpt", + publisher: "publisher", + raw_image_src: "imageUrl", + }, + ], + status: "success", + }, + }); + assert.equal(feed.fetchFromEndpoint.firstCall.args[0], "url"); + assert.equal(feed.fetchFromEndpoint.firstCall.args[1].method, "GET"); + assert.equal( + feed.fetchFromEndpoint.firstCall.args[1].headers.get("consumer_key"), + "oAuthConsumerKeyBff" + ); + }); + }); +}); |