fune/browser/components/newtab/test/unit/lib/TopStoriesFeed.test.js

1900 lines
61 KiB
JavaScript

import { FakePrefs, GlobalOverrider } from "test/unit/utils";
import { actionTypes as at } from "common/Actions.sys.mjs";
import injector from "inject!lib/TopStoriesFeed.jsm";
describe("Top Stories Feed", () => {
let TopStoriesFeed;
let STORIES_UPDATE_TIME;
let TOPICS_UPDATE_TIME;
let SECTION_ID;
let SPOC_IMPRESSION_TRACKING_PREF;
let REC_IMPRESSION_TRACKING_PREF;
let DEFAULT_RECS_EXPIRE_TIME;
let instance;
let clock;
let globals;
let sectionsManagerStub;
let shortURLStub;
const FAKE_OPTIONS = {
stories_endpoint: "https://somedomain.org/stories?key=$apiKey",
stories_referrer: "https://somedomain.org/referrer",
topics_endpoint: "https://somedomain.org/topics?key=$apiKey",
survey_link: "https://www.surveymonkey.com/r/newtabffx",
api_key_pref: "apiKeyPref",
provider_name: "test-provider",
provider_icon: "provider-icon",
provider_description: "provider_desc",
};
beforeEach(() => {
FakePrefs.prototype.prefs.apiKeyPref = "test-api-key";
FakePrefs.prototype.prefs.pocketCta = JSON.stringify({
cta_button: "",
cta_text: "",
cta_url: "",
use_cta: false,
});
globals = new GlobalOverrider();
globals.set("PlacesUtils", { history: {} });
globals.set("pktApi", { isUserLoggedIn() {} });
clock = sinon.useFakeTimers();
shortURLStub = sinon.stub().callsFake(site => site.url);
sectionsManagerStub = {
onceInitialized: sinon.stub().callsFake(callback => callback()),
enableSection: sinon.spy(),
disableSection: sinon.spy(),
updateSection: sinon.spy(),
sections: new Map([["topstories", { options: FAKE_OPTIONS }]]),
};
({
TopStoriesFeed,
STORIES_UPDATE_TIME,
TOPICS_UPDATE_TIME,
SECTION_ID,
SPOC_IMPRESSION_TRACKING_PREF,
REC_IMPRESSION_TRACKING_PREF,
DEFAULT_RECS_EXPIRE_TIME,
} = injector({
"lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs },
"lib/ShortURL.jsm": { shortURL: shortURLStub },
"lib/SectionsManager.jsm": { SectionsManager: sectionsManagerStub },
}));
instance = new TopStoriesFeed();
instance.store = {
getState() {
return {
Prefs: {
values: {
showSponsored: true,
"feeds.section.topstories": true,
},
},
};
},
dispatch: sinon.spy(),
};
instance.storiesLastUpdated = 0;
instance.topicsLastUpdated = 0;
});
afterEach(() => {
globals.restore();
clock.restore();
});
describe("#lazyloading TopStories", () => {
beforeEach(() => {
instance.discoveryStreamEnabled = true;
});
it("should bind parseOptions to SectionsManager.onceInitialized when discovery stream is true", () => {
instance.discoveryStreamEnabled = false;
instance.store.getState = () => ({
Prefs: {
values: {
"discoverystream.config": JSON.stringify({ enabled: true }),
"feeds.section.topstories": true,
},
},
});
instance.onAction({ type: at.INIT, data: {} });
assert.calledOnce(sectionsManagerStub.onceInitialized);
});
it("should bind parseOptions to SectionsManager.onceInitialized when discovery stream is false", () => {
instance.store.getState = () => ({
Prefs: {
values: {
"discoverystream.config": JSON.stringify({ enabled: false }),
"feeds.section.topstories": true,
},
},
});
instance.onAction({ type: at.INIT, data: {} });
assert.calledOnce(sectionsManagerStub.onceInitialized);
});
it("Should initialize properties once while lazy loading if not initialized earlier", () => {
instance.discoveryStreamEnabled = false;
instance.propertiesInitialized = false;
sinon.stub(instance, "initializeProperties");
instance.lazyLoadTopStories();
assert.calledOnce(instance.initializeProperties);
});
it("should not re-initialize properties", () => {
// For discovery stream experience disabled TopStoriesFeed properties
// are initialized in constructor and should not be called again while lazy loading topstories
sinon.stub(instance, "initializeProperties");
instance.discoveryStreamEnabled = false;
instance.propertiesInitialized = true;
instance.lazyLoadTopStories();
assert.notCalled(instance.initializeProperties);
});
it("should have early exit onInit when discovery is true", async () => {
sinon.stub(instance, "doContentUpdate");
await instance.onInit();
assert.notCalled(instance.doContentUpdate);
assert.isUndefined(instance.storiesLoaded);
});
it("should complete onInit when discovery is false", async () => {
instance.discoveryStreamEnabled = false;
sinon.stub(instance, "doContentUpdate");
await instance.onInit();
assert.calledOnce(instance.doContentUpdate);
assert.isTrue(instance.storiesLoaded);
});
it("should handle limited actions when discoverystream is enabled", async () => {
sinon.spy(instance, "handleDisabled");
sinon.stub(instance, "getPocketState");
instance.store.getState = () => ({
Prefs: {
values: {
"discoverystream.config": JSON.stringify({ enabled: true }),
"discoverystream.enabled": true,
"feeds.section.topstories": true,
},
},
});
instance.onAction({ type: at.INIT, data: {} });
assert.calledOnce(instance.handleDisabled);
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
assert.notCalled(instance.getPocketState);
});
it("should handle NEW_TAB_REHYDRATED when discoverystream is disabled", async () => {
instance.discoveryStreamEnabled = false;
sinon.spy(instance, "handleDisabled");
sinon.stub(instance, "getPocketState");
instance.store.getState = () => ({
Prefs: {
values: {
"discoverystream.config": JSON.stringify({ enabled: false }),
"feeds.section.topstories": true,
},
},
});
instance.onAction({ type: at.INIT, data: {} });
assert.notCalled(instance.handleDisabled);
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
assert.calledOnce(instance.getPocketState);
});
it("should handle UNINIT when discoverystream is enabled", async () => {
sinon.stub(instance, "uninit");
instance.onAction({ type: at.UNINIT });
assert.calledOnce(instance.uninit);
});
it("should fire init on PREF_CHANGED", () => {
sinon.stub(instance, "onInit");
instance.onAction({
type: at.PREF_CHANGED,
data: { name: "discoverystream.config", value: {} },
});
assert.calledOnce(instance.onInit);
});
it("should fire init on DISCOVERY_STREAM_PREF_ENABLED", () => {
sinon.stub(instance, "onInit");
instance.onAction({
type: at.PREF_CHANGED,
data: { name: "discoverystream.enabled", value: true },
});
assert.calledOnce(instance.onInit);
});
it("should not fire init on PREF_CHANGED if stories are loaded", () => {
sinon.stub(instance, "onInit");
sinon.spy(instance, "lazyLoadTopStories");
instance.storiesLoaded = true;
instance.onAction({
type: at.PREF_CHANGED,
data: { name: "discoverystream.config", value: {} },
});
assert.calledOnce(instance.lazyLoadTopStories);
assert.notCalled(instance.onInit);
});
it("should fire init on PREF_CHANGED when discoverystream is disabled", () => {
instance.discoveryStreamEnabled = false;
sinon.stub(instance, "onInit");
instance.onAction({
type: at.PREF_CHANGED,
data: { name: "discoverystream.config", value: {} },
});
assert.calledOnce(instance.onInit);
});
it("should not fire init on PREF_CHANGED when discoverystream is disabled and stories are loaded", () => {
instance.discoveryStreamEnabled = false;
sinon.stub(instance, "onInit");
sinon.spy(instance, "lazyLoadTopStories");
instance.storiesLoaded = true;
instance.onAction({
type: at.PREF_CHANGED,
data: { name: "discoverystream.config", value: {} },
});
assert.calledOnce(instance.lazyLoadTopStories);
assert.notCalled(instance.onInit);
});
it("should not init props if ds pref is true", () => {
sinon.stub(instance, "initializeProperties");
instance.propertiesInitialized = false;
instance.store.getState = () => ({
Prefs: {
values: {
"discoverystream.config": JSON.stringify({ enabled: false }),
"discoverystream.enabled": true,
"feeds.section.topstories": true,
},
},
});
instance.lazyLoadTopStories({
dsPref: JSON.stringify({ enabled: true }),
});
assert.notCalled(instance.initializeProperties);
});
it("should fire init if user pref is true", () => {
sinon.stub(instance, "onInit");
instance.store.getState = () => ({
Prefs: {
values: {
"discoverystream.config": JSON.stringify({ enabled: false }),
"discoverystream.enabled": false,
"feeds.section.topstories": false,
},
},
});
instance.lazyLoadTopStories({ userPref: true });
assert.calledOnce(instance.onInit);
});
it("should fire uninit if topstories update to false", () => {
sinon.stub(instance, "uninit");
instance.discoveryStreamEnabled = false;
instance.onAction({
type: at.PREF_CHANGED,
data: {
value: false,
name: "feeds.section.topstories",
},
});
assert.calledOnce(instance.uninit);
instance.discoveryStreamEnabled = true;
instance.onAction({
type: at.PREF_CHANGED,
data: {
value: false,
name: "feeds.section.topstories",
},
});
assert.calledTwice(instance.uninit);
});
it("should fire lazyLoadTopstories if topstories update to true", () => {
sinon.stub(instance, "lazyLoadTopStories");
instance.discoveryStreamEnabled = false;
instance.onAction({
type: at.PREF_CHANGED,
data: {
value: true,
name: "feeds.section.topstories",
},
});
assert.calledOnce(instance.lazyLoadTopStories);
instance.discoveryStreamEnabled = true;
instance.onAction({
type: at.PREF_CHANGED,
data: {
value: true,
name: "feeds.section.topstories",
},
});
assert.calledTwice(instance.lazyLoadTopStories);
});
});
describe("#init", () => {
it("should create a TopStoriesFeed", () => {
assert.instanceOf(instance, TopStoriesFeed);
});
it("should bind parseOptions to SectionsManager.onceInitialized", () => {
instance.onAction({ type: at.INIT, data: {} });
assert.calledOnce(sectionsManagerStub.onceInitialized);
});
it("should initialize endpoints based on options", async () => {
await instance.onInit();
assert.equal(
"https://somedomain.org/stories?key=test-api-key",
instance.stories_endpoint
);
assert.equal(
"https://somedomain.org/referrer",
instance.stories_referrer
);
assert.equal(
"https://somedomain.org/topics?key=test-api-key",
instance.topics_endpoint
);
});
it("should enable its section", () => {
instance.onAction({ type: at.INIT, data: {} });
assert.calledOnce(sectionsManagerStub.enableSection);
assert.calledWith(sectionsManagerStub.enableSection, SECTION_ID);
});
it("init should fire onInit", () => {
instance.onInit = sinon.spy();
instance.onAction({ type: at.INIT, data: {} });
assert.calledOnce(instance.onInit);
});
it("should fetch stories on init", async () => {
instance.fetchStories = sinon.spy();
await instance.onInit();
assert.calledOnce(instance.fetchStories);
});
it("should fetch topics on init", async () => {
instance.fetchTopics = sinon.spy();
await instance.onInit();
assert.calledOnce(instance.fetchTopics);
});
it("should not fetch if endpoint not configured", () => {
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
sectionsManagerStub.sections.set("topstories", { options: {} });
instance.init();
assert.notCalled(fetchStub);
});
it("should report error for invalid configuration", () => {
globals.sandbox.spy(global.console, "error");
sectionsManagerStub.sections.set("topstories", {
options: {
api_key_pref: "invalid",
stories_endpoint: "https://invalid.com/?apiKey=$apiKey",
},
});
instance.init();
assert.calledWith(
console.error,
"Problem initializing top stories feed: An API key was specified but none configured: https://invalid.com/?apiKey=$apiKey"
);
});
it("should report error for missing api key", () => {
globals.sandbox.spy(global.console, "error");
sectionsManagerStub.sections.set("topstories", {
options: {
stories_endpoint: "https://somedomain.org/stories?key=$apiKey",
topics_endpoint: "https://somedomain.org/topics?key=$apiKey",
},
});
instance.init();
assert.called(console.error);
});
it("should load data from cache on init", async () => {
instance.loadCachedData = sinon.spy();
await instance.onInit();
assert.calledOnce(instance.loadCachedData);
});
});
describe("#uninit", () => {
it("should disable its section", () => {
instance.onAction({ type: at.UNINIT });
assert.calledOnce(sectionsManagerStub.disableSection);
assert.calledWith(sectionsManagerStub.disableSection, SECTION_ID);
});
it("should unload stories on uninit", async () => {
sinon.stub(instance.cache, "set").returns(Promise.resolve());
await instance.clearCache();
assert.calledWith(instance.cache.set.firstCall, "stories", {});
assert.calledWith(instance.cache.set.secondCall, "topics", {});
assert.calledWith(instance.cache.set.thirdCall, "spocs", {});
});
});
describe("#cache", () => {
it("should clear all cache items when calling clearCache", () => {
sinon.stub(instance.cache, "set").returns(Promise.resolve());
instance.storiesLoaded = true;
instance.uninit();
assert.equal(instance.storiesLoaded, false);
});
it("should set spocs cache on fetch", async () => {
const response = {
recommendations: [{ id: "1" }, { id: "2" }],
settings: {},
spocs: [{ id: "spoc1" }],
};
instance.show_spocs = true;
instance.stories_endpoint = "stories-endpoint";
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } });
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
sinon.spy(instance.cache, "set");
await instance.fetchStories();
assert.calledOnce(instance.cache.set);
const { args } = instance.cache.set.firstCall;
assert.equal(args[0], "stories");
assert.equal(args[1].spocs[0].id, "spoc1");
});
it("should get spocs on cache load", async () => {
instance.cache.get = () => ({
stories: {
recommendations: [{ id: "1" }, { id: "2" }],
spocs: [{ id: "spoc1" }],
},
});
instance.storiesLastUpdated = 0;
globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } });
await instance.loadCachedData();
assert.equal(instance.spocs[0].guid, "spoc1");
});
});
describe("#fetch", () => {
it("should fetch stories, send event and cache results", async () => {
let fetchStub = globals.sandbox.stub();
sectionsManagerStub.sections.set("topstories", {
options: {
stories_endpoint: "stories-endpoint",
stories_referrer: "referrer",
},
});
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
const response = {
recommendations: [
{
id: "1",
title: "title",
excerpt: "description",
image_src: "image-url",
url: "rec-url",
published_timestamp: "123",
context: "trending",
icon: "icon",
},
],
};
const stories = [
{
guid: "1",
type: "now",
title: "title",
context: "trending",
icon: "icon",
description: "description",
image: "image-url",
referrer: "referrer",
url: "rec-url",
hostname: "rec-url",
score: 1,
spoc_meta: {},
},
];
instance.cache.set = sinon.spy();
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.onInit();
assert.calledOnce(fetchStub);
assert.calledOnce(shortURLStub);
assert.calledWithExactly(fetchStub, instance.stories_endpoint, {
credentials: "omit",
});
assert.calledOnce(sectionsManagerStub.updateSection);
assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {
rows: stories,
});
assert.calledOnce(instance.cache.set);
assert.calledWith(
instance.cache.set,
"stories",
Object.assign({}, response, { _timestamp: 0 })
);
});
it("should use domain as hostname, if present", async () => {
let fetchStub = globals.sandbox.stub();
sectionsManagerStub.sections.set("topstories", {
options: {
stories_endpoint: "stories-endpoint",
stories_referrer: "referrer",
},
});
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
const response = {
recommendations: [
{
id: "1",
title: "title",
excerpt: "description",
image_src: "image-url",
url: "rec-url",
domain: "domain",
published_timestamp: "123",
context: "trending",
icon: "icon",
},
],
};
const stories = [
{
guid: "1",
type: "now",
title: "title",
context: "trending",
icon: "icon",
description: "description",
image: "image-url",
referrer: "referrer",
url: "rec-url",
hostname: "domain",
score: 1,
spoc_meta: {},
},
];
instance.cache.set = sinon.spy();
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.onInit();
assert.calledOnce(fetchStub);
assert.notCalled(shortURLStub);
assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {
rows: stories,
});
});
it("should call SectionsManager.updateSection", () => {
instance.dispatchUpdateEvent(123, {});
assert.calledOnce(sectionsManagerStub.updateSection);
});
it("should report error for unexpected stories response", async () => {
let fetchStub = globals.sandbox.stub();
sectionsManagerStub.sections.set("topstories", {
options: { stories_endpoint: "stories-endpoint" },
});
globals.set("fetch", fetchStub);
globals.sandbox.spy(global.console, "error");
fetchStub.resolves({ ok: false, status: 400 });
await instance.onInit();
assert.calledOnce(fetchStub);
assert.calledWithExactly(fetchStub, instance.stories_endpoint, {
credentials: "omit",
});
assert.equal(instance.storiesLastUpdated, 0);
assert.called(console.error);
});
it("should exclude blocked (dismissed) URLs", async () => {
let fetchStub = globals.sandbox.stub();
sectionsManagerStub.sections.set("topstories", {
options: { stories_endpoint: "stories-endpoint" },
});
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: site => site.url === "blocked" },
});
const response = {
recommendations: [{ url: "blocked" }, { url: "not_blocked" }],
};
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.onInit();
// Issue!
// Should actually be fixed when cache is fixed.
assert.calledOnce(sectionsManagerStub.updateSection);
assert.equal(
sectionsManagerStub.updateSection.firstCall.args[1].rows.length,
1
);
assert.equal(
sectionsManagerStub.updateSection.firstCall.args[1].rows[0].url,
"not_blocked"
);
});
it("should mark stories as new", async () => {
let fetchStub = globals.sandbox.stub();
sectionsManagerStub.sections.set("topstories", {
options: { stories_endpoint: "stories-endpoint" },
});
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
clock.restore();
const response = {
recommendations: [
{ published_timestamp: Date.now() / 1000 },
{ published_timestamp: "0" },
{
published_timestamp: (Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000,
},
],
};
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.onInit();
assert.calledOnce(sectionsManagerStub.updateSection);
assert.equal(
sectionsManagerStub.updateSection.firstCall.args[1].rows.length,
3
);
assert.equal(
sectionsManagerStub.updateSection.firstCall.args[1].rows[0].type,
"now"
);
assert.equal(
sectionsManagerStub.updateSection.firstCall.args[1].rows[1].type,
"trending"
);
assert.equal(
sectionsManagerStub.updateSection.firstCall.args[1].rows[2].type,
"trending"
);
});
it("should fetch topics, send event and cache results", async () => {
let fetchStub = globals.sandbox.stub();
sectionsManagerStub.sections.set("topstories", {
options: { topics_endpoint: "topics-endpoint" },
});
globals.set("fetch", fetchStub);
const response = {
topics: [
{ name: "topic1", url: "url-topic1" },
{ name: "topic2", url: "url-topic2" },
],
};
const topics = [
{
name: "topic1",
url: "url-topic1",
},
{
name: "topic2",
url: "url-topic2",
},
];
instance.cache.set = sinon.spy();
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.onInit();
assert.calledOnce(fetchStub);
assert.calledWithExactly(fetchStub, instance.topics_endpoint, {
credentials: "omit",
});
assert.calledOnce(sectionsManagerStub.updateSection);
assert.calledWithMatch(sectionsManagerStub.updateSection, SECTION_ID, {
topics,
});
assert.calledOnce(instance.cache.set);
assert.calledWith(
instance.cache.set,
"topics",
Object.assign({}, response, { _timestamp: 0 })
);
});
it("should report error for unexpected topics response", async () => {
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
globals.sandbox.spy(global.console, "error");
instance.topics_endpoint = "topics-endpoint";
fetchStub.resolves({ ok: false, status: 400 });
await instance.fetchTopics();
assert.calledOnce(fetchStub);
assert.calledWithExactly(fetchStub, instance.topics_endpoint, {
credentials: "omit",
});
assert.notCalled(instance.store.dispatch);
assert.called(console.error);
});
});
describe("#personalization", () => {
it("should sort stories", async () => {
const response = {
recommendations: [{ id: "1" }, { id: "2" }],
settings: {},
};
instance.compareScore = sinon.spy();
instance.stories_endpoint = "stories-endpoint";
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.fetchStories();
assert.calledOnce(instance.compareScore);
});
it("should sort items based on relevance score", () => {
let items = [{ score: 0.1 }, { score: 0.2 }];
items = items.sort(instance.compareScore);
assert.deepEqual(items, [{ score: 0.2 }, { score: 0.1 }]);
});
it("should rotate items", () => {
let items = [
{ guid: "g1" },
{ guid: "g2" },
{ guid: "g3" },
{ guid: "g4" },
{ guid: "g5" },
{ guid: "g6" },
];
// No impressions should leave items unchanged
let rotated = instance.rotate(items);
assert.deepEqual(items, rotated);
// Recent impression should leave items unchanged
instance._prefs.get = pref =>
pref === REC_IMPRESSION_TRACKING_PREF &&
JSON.stringify({ g1: 1, g2: 1, g3: 1 });
rotated = instance.rotate(items);
assert.deepEqual(items, rotated);
// Impression older than expiration time should rotate items
clock.tick(DEFAULT_RECS_EXPIRE_TIME + 1);
rotated = instance.rotate(items);
assert.deepEqual(
[
{ guid: "g4" },
{ guid: "g5" },
{ guid: "g6" },
{ guid: "g1" },
{ guid: "g2" },
{ guid: "g3" },
],
rotated
);
instance._prefs.get = pref =>
pref === REC_IMPRESSION_TRACKING_PREF &&
JSON.stringify({
g1: 1,
g2: 1,
g3: 1,
g4: DEFAULT_RECS_EXPIRE_TIME + 1,
});
clock.tick(DEFAULT_RECS_EXPIRE_TIME);
rotated = instance.rotate(items);
assert.deepEqual(
[
{ guid: "g5" },
{ guid: "g6" },
{ guid: "g1" },
{ guid: "g2" },
{ guid: "g3" },
{ guid: "g4" },
],
rotated
);
});
it("should record top story impressions", async () => {
instance._prefs = { get: pref => undefined, set: sinon.spy() };
clock.tick(1);
let expectedPrefValue = JSON.stringify({ 1: 1, 2: 1, 3: 1 });
instance.onAction({
type: at.TELEMETRY_IMPRESSION_STATS,
data: {
source: "TOP_STORIES",
tiles: [{ id: 1 }, { id: 2 }, { id: 3 }],
},
});
assert.calledWith(
instance._prefs.set.firstCall,
REC_IMPRESSION_TRACKING_PREF,
expectedPrefValue
);
// Only need to record first impression, so impression pref shouldn't change
instance._prefs.get = pref => expectedPrefValue;
clock.tick(1);
instance.onAction({
type: at.TELEMETRY_IMPRESSION_STATS,
data: {
source: "TOP_STORIES",
tiles: [{ id: 1 }, { id: 2 }, { id: 3 }],
},
});
assert.calledOnce(instance._prefs.set);
// New first impressions should be added
clock.tick(1);
let expectedPrefValueTwo = JSON.stringify({
1: 1,
2: 1,
3: 1,
4: 3,
5: 3,
6: 3,
});
instance.onAction({
type: at.TELEMETRY_IMPRESSION_STATS,
data: {
source: "TOP_STORIES",
tiles: [{ id: 4 }, { id: 5 }, { id: 6 }],
},
});
assert.calledWith(
instance._prefs.set.secondCall,
REC_IMPRESSION_TRACKING_PREF,
expectedPrefValueTwo
);
});
it("should not record top story impressions for non-view impressions", async () => {
instance._prefs = { get: pref => undefined, set: sinon.spy() };
instance.onAction({
type: at.TELEMETRY_IMPRESSION_STATS,
data: { source: "TOP_STORIES", click: 0, tiles: [{ id: 1 }] },
});
assert.notCalled(instance._prefs.set);
instance.onAction({
type: at.TELEMETRY_IMPRESSION_STATS,
data: { source: "TOP_STORIES", block: 0, tiles: [{ id: 1 }] },
});
assert.notCalled(instance._prefs.set);
instance.onAction({
type: at.TELEMETRY_IMPRESSION_STATS,
data: { source: "TOP_STORIES", pocket: 0, tiles: [{ id: 1 }] },
});
assert.notCalled(instance._prefs.set);
});
it("should clean up top story impressions", async () => {
instance._prefs = {
get: pref => JSON.stringify({ 1: 1, 2: 1, 3: 1 }),
set: sinon.spy(),
};
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
instance.stories_endpoint = "stories-endpoint";
const response = { recommendations: [{ id: 3 }, { id: 4 }, { id: 5 }] };
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.fetchStories();
// Should remove impressions for rec 1 and 2 as no longer in the feed
assert.calledWith(
instance._prefs.set.firstCall,
REC_IMPRESSION_TRACKING_PREF,
JSON.stringify({ 3: 1 })
);
});
it("should not change provider with badly formed JSON", async () => {
sinon.stub(instance, "uninit");
sinon.stub(instance, "init");
sinon.stub(instance, "clearCache").returns(Promise.resolve());
await instance.onAction({
type: at.PREF_CHANGED,
data: {
name: "feeds.section.topstories.options",
value: "{version: 2}",
},
});
assert.notCalled(instance.uninit);
assert.notCalled(instance.init);
assert.notCalled(instance.clearCache);
});
});
describe("#spocs", async () => {
it("should not display expired or untimestamped spocs", async () => {
clock.tick(441792000000); // 01/01/1984
instance.spocsPerNewTabs = 1;
instance.show_spocs = true;
instance.isBelowFrequencyCap = () => true;
// NOTE: `expiration_timestamp` is seconds since UNIX epoch
instance.spocs = [
// No timestamp stays visible
{
id: "spoc1",
},
// Expired spoc gets filtered out
{
id: "spoc2",
expiration_timestamp: 1,
},
// Far future expiration spoc stays visible
{
id: "spoc3",
expiration_timestamp: 32503708800, // 01/01/3000
},
];
sinon.spy(instance, "filterSpocs");
instance.filterSpocs();
assert.equal(instance.filterSpocs.firstCall.returnValue.length, 2);
assert.equal(instance.filterSpocs.firstCall.returnValue[0].id, "spoc1");
assert.equal(instance.filterSpocs.firstCall.returnValue[1].id, "spoc3");
});
it("should insert spoc with provided probability", async () => {
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
const response = {
settings: { spocsPerNewTabs: 0.5 },
recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
// Include spocs with a expiration in the very distant future
spocs: [
{ id: "spoc1", expiration_timestamp: 9999999999999 },
{ id: "spoc2", expiration_timestamp: 9999999999999 },
],
};
instance.show_spocs = true;
instance.stories_endpoint = "stories-endpoint";
instance.storiesLoaded = true;
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.fetchStories();
instance.store.getState = () => ({
Sections: [{ id: "topstories", rows: response.recommendations }],
Prefs: { values: { showSponsored: true } },
});
globals.set("Math", {
random: () => 0.4,
min: Math.min,
});
instance.dispatchSpocDone = () => {};
instance.getPocketState = () => {};
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
assert.calledOnce(instance.store.dispatch);
let [action] = instance.store.dispatch.firstCall.args;
assert.equal(at.SECTION_UPDATE, action.type);
assert.equal(true, action.meta.skipMain);
assert.equal(action.data.rows[0].guid, "rec1");
assert.equal(action.data.rows[1].guid, "rec2");
assert.equal(action.data.rows[2].guid, "spoc1");
// Make sure spoc is marked as pinned so it doesn't get removed when preloaded tabs refresh
assert.equal(action.data.rows[2].pinned, true);
// Second new tab shouldn't trigger a section update event (spocsPerNewTab === 0.5)
globals.set("Math", {
random: () => 0.6,
min: Math.min,
});
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
assert.calledOnce(instance.store.dispatch);
globals.set("Math", {
random: () => 0.3,
min: Math.min,
});
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
assert.calledTwice(instance.store.dispatch);
[action] = instance.store.dispatch.secondCall.args;
assert.equal(at.SECTION_UPDATE, action.type);
assert.equal(true, action.meta.skipMain);
assert.equal(action.data.rows[0].guid, "rec1");
assert.equal(action.data.rows[1].guid, "rec2");
assert.equal(action.data.rows[2].guid, "spoc1");
// Make sure spoc is marked as pinned so it doesn't get removed when preloaded tabs refresh
assert.equal(action.data.rows[2].pinned, true);
});
it("should delay inserting spoc if stories haven't been fetched", async () => {
let fetchStub = globals.sandbox.stub();
instance.dispatchSpocDone = () => {};
sectionsManagerStub.sections.set("topstories", {
options: {
show_spocs: true,
stories_endpoint: "stories-endpoint",
},
});
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
globals.set("Math", {
random: () => 0.4,
min: Math.min,
floor: Math.floor,
});
instance.getPocketState = () => {};
instance.dispatchPocketCta = () => {};
const response = {
settings: { spocsPerNewTabs: 0.5 },
recommendations: [{ id: "rec1" }, { id: "rec2" }, { id: "rec3" }],
// Include one spoc with a expiration in the very distant future
spocs: [
{ id: "spoc1", expiration_timestamp: 9999999999999 },
{ id: "spoc2" },
],
};
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
assert.notCalled(instance.store.dispatch);
assert.equal(instance.contentUpdateQueue.length, 1);
instance.spocsPerNewTabs = 0.5;
instance.store.getState = () => ({
Sections: [{ id: "topstories", rows: response.recommendations }],
Prefs: { values: { showSponsored: true } },
});
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.onInit();
assert.equal(instance.contentUpdateQueue.length, 0);
assert.calledOnce(instance.store.dispatch);
let [action] = instance.store.dispatch.firstCall.args;
assert.equal(action.type, at.SECTION_UPDATE);
});
it("should not insert spoc if preffed off", async () => {
let fetchStub = globals.sandbox.stub();
instance.dispatchSpocDone = () => {};
sectionsManagerStub.sections.set("topstories", {
options: {
show_spocs: false,
stories_endpoint: "stories-endpoint",
},
});
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
instance.getPocketState = () => {};
instance.dispatchPocketCta = () => {};
const response = {
settings: { spocsPerNewTabs: 0.5 },
spocs: [{ id: "spoc1" }, { id: "spoc2" }],
};
sinon.spy(instance, "maybeAddSpoc");
sinon.spy(instance, "shouldShowSpocs");
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.onInit();
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
assert.calledOnce(instance.maybeAddSpoc);
assert.calledOnce(instance.shouldShowSpocs);
assert.notCalled(instance.store.dispatch);
});
it("should call dispatchSpocDone when calling maybeAddSpoc", async () => {
instance.dispatchSpocDone = sinon.spy();
instance.storiesLoaded = true;
await instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
assert.calledOnce(instance.dispatchSpocDone);
assert.calledWith(instance.dispatchSpocDone, {});
});
it("should fire POCKET_WAITING_FOR_SPOC action with false", () => {
instance.dispatchSpocDone({});
assert.calledOnce(instance.store.dispatch);
const [action] = instance.store.dispatch.firstCall.args;
assert.equal(action.type, "POCKET_WAITING_FOR_SPOC");
assert.equal(action.data, false);
});
it("should not insert spoc if user opted out", async () => {
let fetchStub = globals.sandbox.stub();
instance.dispatchSpocDone = () => {};
sectionsManagerStub.sections.set("topstories", {
options: {
show_spocs: true,
stories_endpoint: "stories-endpoint",
},
});
instance.getPocketState = () => {};
instance.dispatchPocketCta = () => {};
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
const response = {
settings: { spocsPerNewTabs: 0.5 },
spocs: [{ id: "spoc1" }, { id: "spoc2" }],
};
instance.store.getState = () => ({
Sections: [{ id: "topstories", rows: response.recommendations }],
Prefs: { values: { showSponsored: false } },
});
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.onInit();
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
assert.notCalled(instance.store.dispatch);
});
it("should not fail if there is no spoc", async () => {
let fetchStub = globals.sandbox.stub();
instance.dispatchSpocDone = () => {};
sectionsManagerStub.sections.set("topstories", {
options: {
show_spocs: true,
stories_endpoint: "stories-endpoint",
},
});
instance.getPocketState = () => {};
instance.dispatchPocketCta = () => {};
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
globals.set("Math", {
random: () => 0.4,
min: Math.min,
});
const response = {
settings: { spocsPerNewTabs: 0.5 },
recommendations: [{ id: "rec1" }, { id: "rec2" }, { id: "rec3" }],
};
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.onInit();
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
assert.notCalled(instance.store.dispatch);
});
it("should record spoc/campaign impressions for frequency capping", async () => {
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
globals.set("Math", {
random: () => 0.4,
min: Math.min,
floor: Math.floor,
});
const response = {
settings: { spocsPerNewTabs: 0.5 },
spocs: [
{ id: 1, campaign_id: 5 },
{ id: 4, campaign_id: 6 },
],
};
instance._prefs = { get: pref => undefined, set: sinon.spy() };
instance.show_spocs = true;
instance.stories_endpoint = "stories-endpoint";
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.fetchStories();
let expectedPrefValue = JSON.stringify({ 5: [0] });
let expectedPrefValueCallTwo = JSON.stringify({ 2: 0, 3: 0 });
instance.onAction({
type: at.TELEMETRY_IMPRESSION_STATS,
data: {
source: "TOP_STORIES",
tiles: [{ id: 3 }, { id: 2 }, { id: 1 }],
},
});
assert.calledWith(
instance._prefs.set.firstCall,
SPOC_IMPRESSION_TRACKING_PREF,
expectedPrefValue
);
assert.calledWith(
instance._prefs.set.secondCall,
REC_IMPRESSION_TRACKING_PREF,
expectedPrefValueCallTwo
);
clock.tick(1);
instance._prefs.get = pref => expectedPrefValue;
let expectedPrefValueCallThree = JSON.stringify({ 5: [0, 1] });
let expectedPrefValueCallFour = JSON.stringify({ 2: 1, 3: 1, 5: [0] });
instance.onAction({
type: at.TELEMETRY_IMPRESSION_STATS,
data: {
source: "TOP_STORIES",
tiles: [{ id: 3 }, { id: 2 }, { id: 1 }],
},
});
assert.calledWith(
instance._prefs.set.thirdCall,
SPOC_IMPRESSION_TRACKING_PREF,
expectedPrefValueCallThree
);
assert.calledWith(
instance._prefs.set.getCall(3),
REC_IMPRESSION_TRACKING_PREF,
expectedPrefValueCallFour
);
clock.tick(1);
instance._prefs.get = pref => expectedPrefValueCallThree;
instance.onAction({
type: at.TELEMETRY_IMPRESSION_STATS,
data: {
source: "TOP_STORIES",
tiles: [{ id: 3 }, { id: 2 }, { id: 4 }],
},
});
assert.calledWith(
instance._prefs.set.getCall(4),
SPOC_IMPRESSION_TRACKING_PREF,
JSON.stringify({ 5: [0, 1], 6: [2] })
);
assert.calledWith(
instance._prefs.set.getCall(5),
REC_IMPRESSION_TRACKING_PREF,
JSON.stringify({ 2: 2, 3: 2, 5: [0, 1] })
);
});
it("should not record spoc/campaign impressions for non-view impressions", async () => {
let fetchStub = globals.sandbox.stub();
sectionsManagerStub.sections.set("topstories", {
options: {
show_spocs: true,
stories_endpoint: "stories-endpoint",
},
});
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
const response = {
settings: { spocsPerNewTabs: 0.5 },
spocs: [
{ id: 1, campaign_id: 5 },
{ id: 4, campaign_id: 6 },
],
};
instance._prefs = { get: pref => undefined, set: sinon.spy() };
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.onInit();
instance.onAction({
type: at.TELEMETRY_IMPRESSION_STATS,
data: { source: "TOP_STORIES", click: 0, tiles: [{ id: 1 }] },
});
assert.notCalled(instance._prefs.set);
instance.onAction({
type: at.TELEMETRY_IMPRESSION_STATS,
data: { source: "TOP_STORIES", block: 0, tiles: [{ id: 1 }] },
});
assert.notCalled(instance._prefs.set);
instance.onAction({
type: at.TELEMETRY_IMPRESSION_STATS,
data: { source: "TOP_STORIES", pocket: 0, tiles: [{ id: 1 }] },
});
assert.notCalled(instance._prefs.set);
});
it("should clean up spoc/campaign impressions", async () => {
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
instance._prefs = { get: pref => undefined, set: sinon.spy() };
instance.show_spocs = true;
instance.stories_endpoint = "stories-endpoint";
const response = {
settings: { spocsPerNewTabs: 0.5 },
spocs: [
{ id: 1, campaign_id: 5 },
{ id: 4, campaign_id: 6 },
],
};
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.fetchStories();
// simulate impressions for campaign 5 and 6
instance.onAction({
type: at.TELEMETRY_IMPRESSION_STATS,
data: {
source: "TOP_STORIES",
tiles: [{ id: 3 }, { id: 2 }, { id: 1 }],
},
});
instance._prefs.get = pref =>
pref === SPOC_IMPRESSION_TRACKING_PREF && JSON.stringify({ 5: [0] });
instance.onAction({
type: at.TELEMETRY_IMPRESSION_STATS,
data: {
source: "TOP_STORIES",
tiles: [{ id: 3 }, { id: 2 }, { id: 4 }],
},
});
let expectedPrefValue = JSON.stringify({ 5: [0], 6: [0] });
assert.calledWith(
instance._prefs.set.thirdCall,
SPOC_IMPRESSION_TRACKING_PREF,
expectedPrefValue
);
instance._prefs.get = pref =>
pref === SPOC_IMPRESSION_TRACKING_PREF && expectedPrefValue;
// remove campaign 5 from response
const updatedResponse = {
settings: { spocsPerNewTabs: 1 },
spocs: [{ id: 4, campaign_id: 6 }],
};
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(updatedResponse),
});
await instance.fetchStories();
// should remove campaign 5 from pref as no longer active
assert.calledWith(
instance._prefs.set.getCall(4),
SPOC_IMPRESSION_TRACKING_PREF,
JSON.stringify({ 6: [0] })
);
});
it("should maintain frequency caps when inserting spocs", async () => {
let fetchStub = globals.sandbox.stub();
instance.dispatchSpocDone = () => {};
sectionsManagerStub.sections.set("topstories", {
options: {
show_spocs: true,
stories_endpoint: "stories-endpoint",
},
});
instance.getPocketState = () => {};
instance.dispatchPocketCta = () => {};
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
const response = {
settings: { spocsPerNewTabs: 1 },
recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
spocs: [
// Set spoc `expiration_timestamp`s in the very distant future to ensure they show up
{
id: "spoc1",
campaign_id: 1,
caps: { lifetime: 3, campaign: { count: 2, period: 3600 } },
expiration_timestamp: 999999999999,
},
{
id: "spoc2",
campaign_id: 2,
caps: { lifetime: 1 },
expiration_timestamp: 999999999999,
},
],
};
instance.store.getState = () => ({
Sections: [{ id: "topstories", rows: response.recommendations }],
Prefs: { values: { showSponsored: true } },
});
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.onInit();
instance.spocsPerNewTabs = 1;
clock.tick();
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
let [action] = instance.store.dispatch.firstCall.args;
assert.equal(action.data.rows[0].guid, "rec1");
assert.equal(action.data.rows[1].guid, "rec2");
assert.equal(action.data.rows[2].guid, "spoc1");
instance._prefs.get = pref => JSON.stringify({ 1: [1] });
clock.tick();
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
[action] = instance.store.dispatch.secondCall.args;
assert.equal(action.data.rows[0].guid, "rec1");
assert.equal(action.data.rows[1].guid, "rec2");
assert.equal(action.data.rows[2].guid, "spoc1");
instance._prefs.get = pref => JSON.stringify({ 1: [1, 2] });
// campaign 1 period frequency cap now reached (spoc 2 should be shown)
clock.tick();
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
[action] = instance.store.dispatch.thirdCall.args;
assert.equal(action.data.rows[0].guid, "rec1");
assert.equal(action.data.rows[1].guid, "rec2");
assert.equal(action.data.rows[2].guid, "spoc2");
instance._prefs.get = pref => JSON.stringify({ 1: [1, 2], 2: [3] });
// new campaign 1 period starting (spoc 1 sohuld be shown again)
clock.tick(2 * 60 * 60 * 1000);
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
[action] = instance.store.dispatch.lastCall.args;
assert.equal(action.data.rows[0].guid, "rec1");
assert.equal(action.data.rows[1].guid, "rec2");
assert.equal(action.data.rows[2].guid, "spoc1");
instance._prefs.get = pref =>
JSON.stringify({ 1: [1, 2, 7200003], 2: [3] });
// campaign 1 lifetime cap now reached (no spoc should be sent)
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
assert.callCount(instance.store.dispatch, 4);
});
it("should maintain client-side MAX_LIFETIME_CAP", async () => {
let fetchStub = globals.sandbox.stub();
instance.dispatchSpocDone = () => {};
sectionsManagerStub.sections.set("topstories", {
options: {
show_spocs: true,
stories_endpoint: "stories-endpoint",
},
});
globals.set("fetch", fetchStub);
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
instance.getPocketState = () => {};
instance.dispatchPocketCta = () => {};
const response = {
settings: { spocsPerNewTabs: 1 },
recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
spocs: [{ id: "spoc1", campaign_id: 1, caps: { lifetime: 501 } }],
};
instance.store.getState = () => ({
Sections: [{ id: "topstories", rows: response.recommendations }],
Prefs: { values: { showSponsored: true } },
});
fetchStub.resolves({
ok: true,
status: 200,
json: () => Promise.resolve(response),
});
await instance.onInit();
instance._prefs.get = pref =>
JSON.stringify({ 1: [...Array(500).keys()] });
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
assert.notCalled(instance.store.dispatch);
});
});
describe("#update", () => {
it("should fetch stories after update interval", async () => {
await instance.onInit();
sinon.spy(instance, "fetchStories");
await instance.onAction({ type: at.SYSTEM_TICK });
assert.notCalled(instance.fetchStories);
clock.tick(STORIES_UPDATE_TIME);
await instance.onAction({ type: at.SYSTEM_TICK });
assert.calledOnce(instance.fetchStories);
});
it("should fetch topics after update interval", async () => {
await instance.onInit();
sinon.spy(instance, "fetchTopics");
await instance.onAction({ type: at.SYSTEM_TICK });
assert.notCalled(instance.fetchTopics);
clock.tick(TOPICS_UPDATE_TIME);
await instance.onAction({ type: at.SYSTEM_TICK });
assert.calledOnce(instance.fetchTopics);
});
it("should return updated stories and topics on system tick", async () => {
await instance.onInit();
sinon.spy(instance, "dispatchUpdateEvent");
const stories = [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }];
const topics = [
{ name: "topic1", url: "url-topic1" },
{ name: "topic2", url: "url-topic2" },
];
clock.tick(TOPICS_UPDATE_TIME);
globals.sandbox.stub(instance, "fetchStories").resolves(stories);
globals.sandbox.stub(instance, "fetchTopics").resolves(topics);
await instance.onAction({ type: at.SYSTEM_TICK });
assert.calledOnce(instance.dispatchUpdateEvent);
assert.calledWith(instance.dispatchUpdateEvent, false, {
rows: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
topics: [
{ name: "topic1", url: "url-topic1" },
{ name: "topic2", url: "url-topic2" },
],
read_more_endpoint: undefined,
});
});
it("should not call init and uninit if data doesn't match on options change ", () => {
sinon.spy(instance, "init");
sinon.spy(instance, "uninit");
instance.onAction({ type: at.SECTION_OPTIONS_CHANGED, data: "foo" });
assert.notCalled(sectionsManagerStub.disableSection);
assert.notCalled(sectionsManagerStub.enableSection);
assert.notCalled(instance.init);
assert.notCalled(instance.uninit);
});
it("should call init and uninit on options change", async () => {
sinon.stub(instance, "clearCache").returns(Promise.resolve());
sinon.spy(instance, "init");
sinon.spy(instance, "uninit");
await instance.onAction({
type: at.SECTION_OPTIONS_CHANGED,
data: "topstories",
});
assert.calledOnce(sectionsManagerStub.disableSection);
assert.calledOnce(sectionsManagerStub.enableSection);
assert.calledOnce(instance.clearCache);
assert.calledOnce(instance.init);
assert.calledOnce(instance.uninit);
});
it("should set LastUpdated to 0 on init", async () => {
instance.storiesLastUpdated = 1;
instance.topicsLastUpdated = 1;
await instance.onInit();
assert.equal(instance.storiesLastUpdated, 0);
assert.equal(instance.topicsLastUpdated, 0);
});
it("should filter spocs when link is blocked", async () => {
instance.spocs = [{ url: "not_blocked" }, { url: "blocked" }];
await instance.onAction({
type: at.PLACES_LINK_BLOCKED,
data: { url: "blocked" },
});
assert.deepEqual(instance.spocs, [{ url: "not_blocked" }]);
});
});
describe("#loadCachedData", () => {
it("should update section with cached stories and topics if available", async () => {
sectionsManagerStub.sections.set("topstories", {
options: { stories_referrer: "referrer" },
});
const stories = {
_timestamp: 123,
recommendations: [
{
id: "1",
title: "title",
excerpt: "description",
image_src: "image-url",
url: "rec-url",
published_timestamp: "123",
context: "trending",
icon: "icon",
item_score: 0.98,
},
],
};
const transformedStories = [
{
guid: "1",
type: "now",
title: "title",
context: "trending",
icon: "icon",
description: "description",
image: "image-url",
referrer: "referrer",
url: "rec-url",
hostname: "rec-url",
score: 0.98,
spoc_meta: {},
},
];
const topics = {
_timestamp: 123,
topics: [
{ name: "topic1", url: "url-topic1" },
{ name: "topic2", url: "url-topic2" },
],
};
instance.cache.get = () => ({ stories, topics });
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
await instance.onInit();
assert.calledOnce(sectionsManagerStub.updateSection);
assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {
rows: transformedStories,
topics: topics.topics,
read_more_endpoint: undefined,
});
});
it("should NOT update section if there is no cached data", async () => {
instance.cache.get = () => ({});
globals.set("NewTabUtils", {
blockedLinks: { isBlocked: globals.sandbox.spy() },
});
await instance.loadCachedData();
assert.notCalled(sectionsManagerStub.updateSection);
});
it("should use store rows if no stories sent to doContentUpdate", async () => {
instance.store = {
getState() {
return {
Sections: [{ id: "topstories", rows: [1, 2, 3] }],
};
},
};
sinon.spy(instance, "dispatchUpdateEvent");
instance.doContentUpdate({}, false);
assert.calledOnce(instance.dispatchUpdateEvent);
assert.calledWith(instance.dispatchUpdateEvent, false, {
rows: [1, 2, 3],
});
});
it("should broadcast in doContentUpdate when updating from cache", async () => {
sectionsManagerStub.sections.set("topstories", {
options: { stories_referrer: "referrer" },
});
globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } });
const stories = { recommendations: [{}] };
const topics = { topics: [{}] };
sinon.spy(instance, "doContentUpdate");
instance.cache.get = () => ({ stories, topics });
await instance.onInit();
assert.calledOnce(instance.doContentUpdate);
assert.calledWith(
instance.doContentUpdate,
{
stories: [
{
context: undefined,
description: undefined,
guid: undefined,
hostname: undefined,
icon: undefined,
image: undefined,
referrer: "referrer",
score: 1,
spoc_meta: {},
title: undefined,
type: "trending",
url: undefined,
},
],
topics: [{}],
},
true
);
});
});
describe("#pocket", () => {
it("should call getPocketState when hitting NEW_TAB_REHYDRATED", () => {
instance.getPocketState = sinon.spy();
instance.onAction({
type: at.NEW_TAB_REHYDRATED,
meta: { fromTarget: {} },
});
assert.calledOnce(instance.getPocketState);
assert.calledWith(instance.getPocketState, {});
});
it("should call dispatch in getPocketState", () => {
const isUserLoggedIn = sinon.spy();
globals.set("pktApi", { isUserLoggedIn });
instance.getPocketState({});
assert.calledOnce(instance.store.dispatch);
const [action] = instance.store.dispatch.firstCall.args;
assert.equal(action.type, "POCKET_LOGGED_IN");
assert.calledOnce(isUserLoggedIn);
});
it("should call dispatchPocketCta when hitting onInit", async () => {
instance.dispatchPocketCta = sinon.spy();
await instance.onInit();
assert.calledOnce(instance.dispatchPocketCta);
assert.calledWith(
instance.dispatchPocketCta,
JSON.stringify({
cta_button: "",
cta_text: "",
cta_url: "",
use_cta: false,
}),
false
);
});
it("should call dispatch in dispatchPocketCta", () => {
instance.dispatchPocketCta(JSON.stringify({ use_cta: true }), false);
assert.calledOnce(instance.store.dispatch);
const [action] = instance.store.dispatch.firstCall.args;
assert.equal(action.type, "POCKET_CTA");
assert.equal(action.data.use_cta, true);
});
it("should call dispatchPocketCta with a pocketCta pref change", () => {
instance.dispatchPocketCta = sinon.spy();
instance.onAction({
type: at.PREF_CHANGED,
data: {
name: "pocketCta",
value: JSON.stringify({
cta_button: "",
cta_text: "",
cta_url: "",
use_cta: false,
}),
},
});
assert.calledOnce(instance.dispatchPocketCta);
assert.calledWith(
instance.dispatchPocketCta,
JSON.stringify({
cta_button: "",
cta_text: "",
cta_url: "",
use_cta: false,
}),
true
);
});
});
it("should call uninit and init on disabling of showSponsored pref", async () => {
sinon.stub(instance, "clearCache").returns(Promise.resolve());
sinon.stub(instance, "uninit");
sinon.stub(instance, "init");
await instance.onAction({
type: at.PREF_CHANGED,
data: { name: "showSponsored", value: false },
});
assert.calledOnce(instance.clearCache);
assert.calledOnce(instance.uninit);
assert.calledOnce(instance.init);
});
});