Source code
Revision control
Copy as Markdown
Other Tools
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
import {
actionCreators as ac,
actionTypes as at,
actionUtils as au,
} from "common/Actions.mjs";
import { combineReducers, createStore } from "redux";
import { mockServices, stubGlobals } from "test/jest/test-utils";
import { DiscoveryStreamFeed } from "lib/DiscoveryStreamFeed.sys.mjs";
import { reducers } from "common/Reducers.sys.mjs";
import { PersistentCache } from "lib/PersistentCache.sys.mjs";
import {
SectionsLayoutManager,
maskLayoutAds,
} from "lib/SectionsLayoutFeed.sys.mjs";
const CONFIG_PREF_NAME = "discoverystream.config";
const ENDPOINTS_PREF_NAME = "discoverystream.endpoints";
const SPOC_IMPRESSION_TRACKING_PREF = "discoverystream.spoc.impressions";
const THIRTY_MINUTES = 30 * 60 * 1000;
const ONE_WEEK = 7 * 24 * 60 * 60 * 1000; // 1 week
const FAKE_UUID = "{foo-123-foo}";
const DEFAULT_COLUMN_COUNT = 4;
const DEFAULT_ROW_COUNT = 6;
// eslint-disable-next-line max-statements
describe("DiscoveryStreamFeed", () => {
let feed;
let feeds;
let fetchStub;
let fakeNewTabUtils;
let services;
let restoreGlobals;
let surfaceIdSet;
// The feed logs the failures these cases drive it into. Swap out the spy the
// jest-setup console.error guard installed for the duration of the case, so
// that expected logging is not reported against it. jest-setup puts the real
// console.error back in its afterEach.
const expectConsoleError = () => {
console.error = jest.fn();
return console.error;
};
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);
};
const stubOutFetchFromEndpointWithRealisticData = () => {
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue({
recommendedAt: 1755834072383,
surfaceId: "NEW_TAB_EN_US",
data: [
{
corpusItemId: "decaf-c0ff33",
scheduledCorpusItemId: "matcha-latte-ff33c1",
excerpt: "excerpt",
iconUrl: "iconUrl",
imageUrl: "imageUrl",
isTimeSensitive: true,
publisher: "publisher",
receivedRank: 0,
tileId: 12345,
title: "title",
topic: "topic",
url: "url",
features: {},
},
{
corpusItemId: "decaf-c0ff34",
scheduledCorpusItemId: "matcha-latte-ff33c2",
excerpt: "excerpt",
iconUrl: "iconUrl",
imageUrl: "imageUrl",
isTimeSensitive: true,
publisher: "publisher",
receivedRank: 0,
tileId: 12346,
title: "title",
topic: "topic",
url: "url",
features: {},
},
],
settings: {
recsExpireTime: 1,
},
});
};
beforeEach(() => {
// Fetch
fetchStub = jest.fn();
// Time
jest.useFakeTimers({ now: 0 });
fakeNewTabUtils = {
blockedLinks: {
links: [],
isBlocked: () => false,
},
getUtcOffset: () => 0,
};
services = mockServices(["locale", "obs", "prefs", "uuid"]);
services.uuid.generateUUID.mockReturnValue(FAKE_UUID);
services.prefs.getBoolPref.mockImplementation(name =>
name === "browser.newtabpage.activity-stream.discoverystream.enabled"
? true
: undefined
);
surfaceIdSet = jest.fn();
restoreGlobals = stubGlobals({
fetch: fetchStub,
Glean: { newtabContent: { surfaceId: { set: surfaceIdSet } } },
Services: services,
PersistentCache,
PathUtils: {
join: (...parts) => parts[parts.length - 1],
localProfileDir: "localProfileDir",
},
IOUtils: {
readJSON: () => Promise.resolve({}),
writeJSON: () => Promise.resolve(0),
},
NewTabUtils: fakeNewTabUtils,
ContextId: {
request: () => "ContextId",
},
AdsClient: {
isEnabled: () => false,
getClient: () => null,
},
MozAdsPlacementRequestWithCount: class {
constructor(obj) {
this.obj = obj;
}
},
MozAdsIabContent: class {
constructor(obj) {
this.obj = obj;
}
},
MozAdsIabContentTaxonomy: {},
NimbusFeatures: {
pocketNewtab: {
getEnrollmentMetadata: jest.fn(),
onUpdate: jest.fn(),
offUpdate: jest.fn(),
},
},
ObliviousHTTP: {
getOHTTPConfig: () => {},
ohttpRequest: () => {},
},
Region: { home: "US" },
RemoteSettings: { pollChanges: jest.fn() },
// Only read through the lazy.userAgent getter.
Cc: {
"@mozilla.org/network/protocol;1?name=http": {
getService() {
return this;
},
},
},
Ci: { nsIHttpProtocolHandler: {} },
// The layout globals are only installed by the tests that need them.
SectionsLayoutManager: undefined,
maskLayoutAds: undefined,
});
// Feed
feed = new DiscoveryStreamFeed();
feed.store = createStore(combineReducers(reducers), {
Prefs: {
values: {
[CONFIG_PREF_NAME]: JSON.stringify({
enabled: false,
}),
[ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
"discoverystream.enabled": true,
"feeds.section.topstories": true,
"feeds.system.topstories": true,
"system.showSponsored": false,
"discoverystream.spocs.startupCache.enabled": true,
"unifiedAds.adsFeed.enabled": false,
},
},
});
feed.store.feeds = {
get: name => feeds[name],
};
jest.spyOn(feed, "_maybeUpdateCachedData").mockResolvedValue();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
restoreGlobals();
});
describe("#fetchFromEndpoint", () => {
beforeEach(() => {
fetchStub.mockResolvedValue({
json: () => Promise.resolve("hi"),
ok: true,
});
});
it("should get a response", async () => {
const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
expect(response).toBe("hi");
});
it("should not send cookies", async () => {
await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
expect(fetchStub.mock.calls[0][1]).toHaveProperty("credentials", "omit");
});
it("should allow unexpected response", async () => {
expectConsoleError();
fetchStub.mockResolvedValue({ ok: false });
const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
expect(response).toBe(null);
});
it("should disallow unexpected endpoints", async () => {
expectConsoleError();
feed.store.getState = () => ({
},
});
const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
expect(response).toBe(null);
});
it("should allow multiple endpoints", async () => {
feed.store.getState = () => ({
},
});
const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
expect(response).toBe("hi");
});
it("should ignore white-space added to multiple endpoints", async () => {
feed.store.getState = () => ({
},
});
const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
expect(response).toBe("hi");
});
it("should allow POST and with other options", async () => {
method: "POST",
body: "{}",
});
expect(fetchStub).toHaveBeenCalledWith(
expect.objectContaining({
credentials: "omit",
method: "POST",
body: "{}",
})
);
});
it("should use OHTTP when configured and enabled", async () => {
services.prefs.getStringPref.mockImplementation(name => {
switch (name) {
case "browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL":
case "browser.newtabpage.activity-stream.discoverystream.ohttp.configURL":
default:
return undefined;
}
});
const fakeOhttpConfig = { config: "config" };
jest
.spyOn(globalThis.ObliviousHTTP, "getOHTTPConfig")
.mockResolvedValue(fakeOhttpConfig);
const ohttpResponse = {
json: () => Promise.resolve("ohttp response"),
ok: true,
};
const ohttpRequestStub = jest
.spyOn(globalThis.ObliviousHTTP, "ohttpRequest")
.mockResolvedValue(ohttpResponse);
// Allow the endpoint
feed.store.getState = () => ({
Prefs: {
values: {
[ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
},
},
});
const result = await feed.fetchFromEndpoint(DUMMY_ENDPOINT, {}, true);
expect(result).toBe("ohttp response");
expect(ohttpRequestStub).toHaveBeenCalledTimes(1);
expect(ohttpRequestStub).toHaveBeenCalledWith(
fakeOhttpConfig,
DUMMY_ENDPOINT,
expect.anything()
);
});
it("should cast headers from a Headers object to JS object when using OHTTP", async () => {
services.prefs.getStringPref.mockImplementation(name => {
switch (name) {
case "browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL":
case "browser.newtabpage.activity-stream.discoverystream.ohttp.configURL":
default:
return undefined;
}
});
const fakeOhttpConfig = { config: "config" };
jest
.spyOn(globalThis.ObliviousHTTP, "getOHTTPConfig")
.mockResolvedValue(fakeOhttpConfig);
const ohttpResponse = {
json: () => Promise.resolve("ohttp response"),
ok: true,
};
const ohttpRequestStub = jest
.spyOn(globalThis.ObliviousHTTP, "ohttpRequest")
.mockResolvedValue(ohttpResponse);
// Allow the endpoint
feed.store.getState = () => ({
Prefs: {
values: {
[ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
},
},
});
const headers = new Headers();
headers.set("headername", "headervalue");
const result = await feed.fetchFromEndpoint(
DUMMY_ENDPOINT,
{ headers },
true
);
expect(result).toBe("ohttp response");
expect(ohttpRequestStub).toHaveBeenCalledTimes(1);
expect(ohttpRequestStub).toHaveBeenCalledWith(
fakeOhttpConfig,
DUMMY_ENDPOINT,
expect.objectContaining({
headers: Object.fromEntries(headers),
credentials: "omit",
})
);
});
});
describe("#getOrCreateImpressionId", () => {
it("should create impression id in constructor", async () => {
expect(feed._impressionId).toBe(FAKE_UUID);
});
it("should create impression id if none exists", async () => {
services.prefs.getCharPref.mockReturnValue("");
// The constructor already created one, so only count this call.
services.prefs.setCharPref.mockClear();
const result = feed.getOrCreateImpressionId();
expect(result).toBe(FAKE_UUID);
expect(globalThis.Services.prefs.setCharPref).toHaveBeenCalledTimes(1);
});
it("should use impression id if exists", async () => {
services.prefs.getCharPref.mockReturnValue("from get");
// The constructor already read the pref, so only count this call.
services.prefs.getCharPref.mockClear();
const result = feed.getOrCreateImpressionId();
expect(result).toBe("from get");
expect(globalThis.Services.prefs.getCharPref).toHaveBeenCalledTimes(1);
});
});
describe("#parseGridPositions", () => {
it("should return an equivalent array for an array of non negative integers", async () => {
expect(feed.parseGridPositions([0, 2, 3])).toEqual([0, 2, 3]);
});
it("should return undefined for an array containing negative integers", async () => {
expect(feed.parseGridPositions([-2, 2, 3])).toBe(undefined);
});
it("should return undefined for an undefined input", async () => {
expect(feed.parseGridPositions(undefined)).toBe(undefined);
});
});
describe("#loadLayout", () => {
it("should use local basic layout with hardcoded_basic_layout being true", async () => {
feed.config.hardcoded_basic_layout = true;
await feed.loadLayout(feed.store.dispatch);
expect(feed.store.getState().DiscoveryStream.spocs.spocs_endpoint).toBe(
);
const { layout } = feed.store.getState().DiscoveryStream;
expect(layout[0].components[2].properties.items).toBe(
DEFAULT_COLUMN_COUNT
);
});
it("should use 1 row layout if specified", async () => {
feed.store = createStore(combineReducers(reducers), {
Prefs: {
values: {
[CONFIG_PREF_NAME]: JSON.stringify({
enabled: true,
}),
[ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
"discoverystream.enabled": true,
"discoverystream.region-basic-layout": true,
"system.showSponsored": false,
},
},
});
await feed.loadLayout(feed.store.dispatch);
const { layout } = feed.store.getState().DiscoveryStream;
expect(layout[0].components[2].properties.items).toBe(
DEFAULT_COLUMN_COUNT
);
});
it("should use 6 row layout if specified", async () => {
feed.store = createStore(combineReducers(reducers), {
Prefs: {
values: {
[CONFIG_PREF_NAME]: JSON.stringify({
enabled: true,
}),
[ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
"discoverystream.enabled": true,
"discoverystream.region-basic-layout": false,
"system.showSponsored": false,
},
},
});
await feed.loadLayout(feed.store.dispatch);
const { layout } = feed.store.getState().DiscoveryStream;
expect(layout[0].components[2].properties.items).toBe(
DEFAULT_ROW_COUNT * DEFAULT_COLUMN_COUNT
);
});
it("should use local basic layout with FF pref hardcoded_basic_layout", async () => {
feed.store = createStore(combineReducers(reducers), {
Prefs: {
values: {
[CONFIG_PREF_NAME]: JSON.stringify({
enabled: false,
}),
[ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
"discoverystream.enabled": true,
"discoverystream.hardcoded-basic-layout": true,
"system.showSponsored": false,
},
},
});
await feed.loadLayout(feed.store.dispatch);
expect(feed.store.getState().DiscoveryStream.spocs.spocs_endpoint).toBe(
);
const { layout } = feed.store.getState().DiscoveryStream;
expect(layout[0].components[2].properties.items).toBe(
DEFAULT_COLUMN_COUNT
);
});
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,
}),
[ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
"discoverystream.enabled": true,
"discoverystream.spocs-endpoint":
"system.showSponsored": false,
},
},
});
await feed.loadLayout(feed.store.dispatch);
expect(feed.store.getState().DiscoveryStream.spocs.spocs_endpoint).toBe(
);
});
it("should return enough stories to fill a four card layout", async () => {
expectConsoleError();
feed.store = createStore(combineReducers(reducers), {
Prefs: {
values: {
pocketConfig: { fourCardLayout: true },
},
},
});
await feed.loadLayout(feed.store.dispatch);
const { layout } = feed.store.getState().DiscoveryStream;
expect(layout[0].components[2].properties.items).toBe(
DEFAULT_ROW_COUNT * DEFAULT_COLUMN_COUNT
);
});
it("should create a layout with spoc and widget positions", async () => {
expectConsoleError();
feed.store = createStore(combineReducers(reducers), {
Prefs: {
values: {
"discoverystream.spoc-positions": "1, 2",
pocketConfig: {
widgetPositions: "3, 4",
},
},
},
});
await feed.loadLayout(feed.store.dispatch);
const { layout } = feed.store.getState().DiscoveryStream;
expect(layout[0].components[2].spocs.positions).toEqual([
{ index: 1 },
{ index: 2 },
]);
expect(layout[0].components[2].widgets.positions).toEqual([
{ index: 3 },
{ index: 4 },
]);
});
it("should create a layout with spoc position data", async () => {
expectConsoleError();
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;
expect(layout[0].components[2].placement.ad_types).toEqual([1230]);
expect(layout[0].components[2].placement.zone_ids).toEqual([4560, 7890]);
});
it("should create a layout with proper spoc url with a site id", async () => {
expectConsoleError();
feed.store = createStore(combineReducers(reducers), {
Prefs: {
values: {
pocketConfig: {
spocSiteId: "1234",
},
},
},
});
await feed.loadLayout(feed.store.dispatch);
const { spocs } = feed.store.getState().DiscoveryStream;
expect(spocs.spocs_endpoint).toEqual(
);
});
});
describe("#updatePlacements", () => {
it("should dispatch DISCOVERY_STREAM_SPOCS_PLACEMENTS", () => {
jest.spyOn(feed.store, "dispatch");
feed.store.getState = () => ({
Prefs: {
values: { showSponsored: true, "system.showSponsored": true },
},
});
const fakeComponents = {
components: [
{ placement: { name: "first" }, spocs: {} },
{ placement: { name: "second" }, spocs: {} },
],
};
const fakeLayout = [fakeComponents];
feed.updatePlacements(feed.store.dispatch, fakeLayout);
expect(feed.store.dispatch).toHaveBeenCalledTimes(1);
expect(feed.store.dispatch).toHaveBeenCalledWith({
type: "DISCOVERY_STREAM_SPOCS_PLACEMENTS",
data: { placements: [{ name: "first" }, { name: "second" }] },
meta: { isStartup: false },
});
});
it("should fire update placements from loadLayout", async () => {
jest.spyOn(feed, "updatePlacements");
await feed.loadLayout(feed.store.dispatch);
expect(feed.updatePlacements).toHaveBeenCalledTimes(1);
});
});
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));
expect(items).toEqual(["first", "second"]);
});
});
describe("#loadComponentFeeds", () => {
let fakeCache;
let fakeDiscoveryStream;
beforeEach(() => {
fakeDiscoveryStream = {
Prefs: {
values: {
"discoverystream.spocs.startupCache.enabled": true,
},
},
DiscoveryStream: {
layout: [
{ components: [{ feed: { url: "foo.com" } }] },
{ components: [{}] },
{},
],
},
};
fakeCache = {};
jest.spyOn(feed.store, "getState").mockReturnValue(fakeDiscoveryStream);
jest.spyOn(feed.cache, "set").mockReturnValue(Promise.resolve());
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should not dispatch updates when layout is not defined", async () => {
fakeDiscoveryStream = {
DiscoveryStream: {},
};
feed.store.getState.mockReturnValue(fakeDiscoveryStream);
jest.spyOn(feed.store, "dispatch");
await feed.loadComponentFeeds(feed.store.dispatch);
expect(feed.store.dispatch).not.toHaveBeenCalled();
});
it("should populate feeds cache", async () => {
fakeCache = {
feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } },
};
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve(fakeCache));
await feed.loadComponentFeeds(feed.store.dispatch);
expect(feed.cache.set).toHaveBeenCalledWith("feeds", {
"foo.com": { data: "data", lastUpdated: 0 },
});
});
it("should send feed update events with new feed data", async () => {
expectConsoleError();
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve(fakeCache));
jest.spyOn(feed.store, "dispatch");
await feed.loadComponentFeeds(feed.store.dispatch);
expect(feed.store.dispatch).toHaveBeenNthCalledWith(1, {
type: at.DISCOVERY_STREAM_FEED_UPDATE,
data: { feed: { data: { status: "failed" } }, url: "foo.com" },
meta: { isStartup: false },
});
expect(feed.store.dispatch).toHaveBeenNthCalledWith(2, {
type: at.DISCOVERY_STREAM_FEEDS_UPDATE,
meta: { isStartup: false },
});
});
it("should return number of promises equal to unique urls", async () => {
expectConsoleError();
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve(fakeCache));
jest.spyOn(Promise, "all").mockResolvedValue();
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.mockReturnValue(fakeDiscoveryStream);
await feed.loadComponentFeeds(feed.store.dispatch);
expect(Promise.all).toHaveBeenCalledTimes(1);
const [args] = Promise.all.mock.lastCall;
expect(args.length).toBe(3);
});
});
describe("#getComponentFeed", () => {
it("should fetch fresh feed data if cache is empty", async () => {
const fakeCache = {};
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve(fakeCache));
jest.spyOn(feed, "rotate").mockImplementation(val => val);
jest
.spyOn(feed, "scoreItemsInferred")
.mockImplementation(val => ({ data: val, filtered: [] }));
stubOutFetchFromEndpointWithRealisticData();
const feedResp = await feed.getComponentFeed("foo.com");
expect(feedResp.data.recommendations.length).toBe(2);
});
it("should fetch fresh feed data if cache is old", async () => {
const fakeCache = { feeds: { "foo.com": { lastUpdated: Date.now() } } };
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve(fakeCache));
stubOutFetchFromEndpointWithRealisticData();
jest.spyOn(feed, "rotate").mockImplementation(val => val);
jest
.spyOn(feed, "scoreItemsInferred")
.mockImplementation(val => ({ data: val, filtered: [] }));
jest.advanceTimersByTime(THIRTY_MINUTES + 1);
const feedResp = await feed.getComponentFeed("foo.com");
expect(feedResp.data.recommendations.length).toBe(2);
});
it("should return feed data from cache if it is fresh", async () => {
const fakeCache = {
feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } },
};
jest.spyOn(feed.cache, "get").mockResolvedValue(fakeCache);
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue("old data");
jest.advanceTimersByTime(THIRTY_MINUTES - 1);
const feedResp = await feed.getComponentFeed("foo.com");
expect(feedResp.data).toBe("data");
});
it("should return null if no response was received", async () => {
expectConsoleError();
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue(null);
const feedResp = await feed.getComponentFeed("foo.com");
expect(feedResp).toEqual({ data: { status: "failed" } });
});
function stubOutComponentFeedDeps() {
jest.spyOn(feed.cache, "get").mockResolvedValue({});
jest.spyOn(feed, "rotate").mockImplementation(val => val);
jest
.spyOn(feed, "scoreItemsInferred")
.mockImplementation(val => ({ data: val, filtered: [] }));
stubOutFetchFromEndpointWithRealisticData();
}
function setPrefCalledFor(dispatchSpy, name) {
return dispatchSpy.mock.calls.some(
([action]) =>
action?.type === at.SET_PREF && action?.data?.name === name
);
}
it("should record surfaceId in Glean when the private ping is enabled", async () => {
stubOutComponentFeedDeps();
setPref("telemetry.privatePing.enabled", true);
await feed.getComponentFeed("foo.com");
expect(surfaceIdSet).toHaveBeenCalledWith("NEW_TAB_EN_US");
});
it("should record surfaceId in Glean when the private ping is disabled", async () => {
stubOutComponentFeedDeps();
setPref("telemetry.privatePing.enabled", false);
await feed.getComponentFeed("foo.com");
expect(surfaceIdSet).toHaveBeenCalledWith("NEW_TAB_EN_US");
});
it("should not update the surfaceId pref when the private ping is disabled", async () => {
stubOutComponentFeedDeps();
setPref("telemetry.privatePing.enabled", false);
const dispatchSpy = jest.spyOn(feed.store, "dispatch");
await feed.getComponentFeed("foo.com");
expect(setPrefCalledFor(dispatchSpy, "telemetry.surfaceId")).toBe(false);
});
it("should update the surfaceId pref when the private ping is enabled", async () => {
stubOutComponentFeedDeps();
setPref("telemetry.privatePing.enabled", true);
const dispatchSpy = jest.spyOn(feed.store, "dispatch");
await feed.getComponentFeed("foo.com");
expect(setPrefCalledFor(dispatchSpy, "telemetry.surfaceId")).toBe(true);
});
});
describe("#loadSpocs", () => {
beforeEach(() => {
jest.spyOn(feed, "getPlacements").mockReturnValue([{ name: "spocs" }]);
Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
});
it("should not fetch or update cache if no spocs endpoint is defined", async () => {
expectConsoleError();
feed.store.dispatch(
ac.BroadcastToContent({
type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT,
data: "",
})
);
jest.spyOn(feed.cache, "set");
await feed.loadSpocs(feed.store.dispatch);
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(feed.cache.set).toHaveBeenCalledWith("spocs", {
lastUpdated: 0,
spocs: {},
spocsOnDemand: undefined,
spocsCacheUpdateTime: 30 * 60 * 1000,
});
});
it("should fetch fresh spocs data if cache is empty", async () => {
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve());
jest
.spyOn(feed, "fetchFromEndpoint")
.mockResolvedValue({ placement: "data" });
jest.spyOn(feed.cache, "set").mockReturnValue(Promise.resolve());
await feed.loadSpocs(feed.store.dispatch);
expect(feed.cache.set).toHaveBeenCalledWith("spocs", {
spocs: { placement: "data" },
lastUpdated: 0,
spocsOnDemand: undefined,
spocsCacheUpdateTime: 30 * 60 * 1000,
});
expect(feed.store.getState().DiscoveryStream.spocs.data.placement).toBe(
"data"
);
});
it("should fetch fresh data if cache is old", async () => {
const cachedSpoc = {
spocs: { placement: "old" },
lastUpdated: Date.now(),
};
const cachedData = { spocs: cachedSpoc };
jest
.spyOn(feed.cache, "get")
.mockReturnValue(Promise.resolve(cachedData));
jest
.spyOn(feed, "fetchFromEndpoint")
.mockResolvedValue({ placement: "new" });
jest.spyOn(feed.cache, "set").mockReturnValue(Promise.resolve());
jest.advanceTimersByTime(THIRTY_MINUTES + 1);
await feed.loadSpocs(feed.store.dispatch);
expect(feed.store.getState().DiscoveryStream.spocs.data.placement).toBe(
"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 };
jest
.spyOn(feed.cache, "get")
.mockReturnValue(Promise.resolve(cachedData));
jest
.spyOn(feed, "fetchFromEndpoint")
.mockResolvedValue({ placement: "new" });
jest.spyOn(feed.cache, "set").mockReturnValue(Promise.resolve());
jest.advanceTimersByTime(THIRTY_MINUTES - 1);
await feed.loadSpocs(feed.store.dispatch);
expect(feed.store.getState().DiscoveryStream.spocs.data.placement).toBe(
"old"
);
});
it("should properly transform spocs using placements", async () => {
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve());
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue({
spocs: { items: [{ id: "data" }] },
});
jest.spyOn(feed.cache, "set").mockReturnValue(Promise.resolve());
const loadTimestamp = 100;
jest.advanceTimersByTime(loadTimestamp);
await feed.loadSpocs(feed.store.dispatch);
expect(feed.cache.set).toHaveBeenCalledWith("spocs", {
spocs: {
spocs: {
context: "",
title: "",
sponsor: "",
sponsored_by_override: undefined,
items: [{ id: "data", score: 1 }],
},
},
lastUpdated: loadTimestamp,
spocsOnDemand: undefined,
spocsCacheUpdateTime: 30 * 60 * 1000,
});
expect(
feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0]
).toEqual({ id: "data", score: 1 });
});
it("should normalizeSpocsItems for older spoc data", async () => {
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve());
jest
.spyOn(feed, "fetchFromEndpoint")
.mockResolvedValue({ spocs: [{ id: "data" }] });
jest.spyOn(feed.cache, "set").mockReturnValue(Promise.resolve());
await feed.loadSpocs(feed.store.dispatch);
expect(
feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0]
).toEqual({ id: "data", score: 1 });
});
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.mockRestore();
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve());
jest
.spyOn(feed, "fetchFromEndpoint")
.mockResolvedValue({ placement1: [{ id: "data" }], placement2: [] });
jest.spyOn(feed.cache, "set").mockReturnValue(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);
expect(feed.store.getState().DiscoveryStream.spocs.data).toEqual({
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.mockRestore();
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve());
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue({
placement1: {
title: "title",
context: "context",
sponsor: "",
sponsored_by_override: undefined,
items: [{ id: "data" }],
},
});
jest.spyOn(feed.cache, "set").mockReturnValue(Promise.resolve());
const fakeComponents = {
components: [{ placement: { name: "placement1" }, spocs: {} }],
};
feed.updatePlacements(feed.store.dispatch, [fakeComponents]);
await feed.loadSpocs(feed.store.dispatch);
expect(feed.store.getState().DiscoveryStream.spocs.data).toEqual({
placement1: {
title: "title",
context: "context",
sponsor: "",
sponsored_by_override: undefined,
items: [{ id: "data", score: 1 }],
},
});
});
it("should fetch MARS pre flight info", async () => {
expectConsoleError();
jest
.spyOn(feed, "fetchFromEndpoint")
.mockImplementation((endpoint, options) =>
endpoint === "unifiedAdEndpoint/v1/ads-preflight" &&
options?.method === "GET"
? Promise.resolve({
normalized_ua: "normalized_ua",
geoname_id: "geoname_id",
geo_location: "geo_location",
})
: undefined
);
feed.store = createStore(combineReducers(reducers), {
Prefs: {
values: {
"unifiedAds.endpoint": "unifiedAdEndpoint/",
"unifiedAds.blockedAds": "",
"unifiedAds.spocs.enabled": true,
"discoverystream.placements.spocs": "newtab_stories_1",
"discoverystream.placements.spocs.counts": "1",
"unifiedAds.ohttp.enabled": true,
},
},
});
await feed.loadSpocs(feed.store.dispatch);
expect(feed.fetchFromEndpoint.mock.calls[0][0]).toBe(
"unifiedAdEndpoint/v1/ads-preflight"
);
expect(feed.fetchFromEndpoint.mock.calls[0][1].method).toBe("GET");
expect(feed.fetchFromEndpoint.mock.calls[1][0]).toBe(
"unifiedAdEndpoint/v1/ads"
);
expect(
feed.fetchFromEndpoint.mock.calls[1][1].headers.get("X-User-Agent")
).toBe("normalized_ua");
expect(
feed.fetchFromEndpoint.mock.calls[1][1].headers.get("X-Geoname-ID")
).toBe("geoname_id");
expect(
feed.fetchFromEndpoint.mock.calls[1][1].headers.get("X-Geo-Location")
).toBe("geo_location");
});
it("should fetch ads with empty flags if adsBackend flags are empty", async () => {
feed.store = createStore(combineReducers(reducers), {
Prefs: {
values: {
"unifiedAds.endpoint": "unifiedAdEndpoint/",
"unifiedAds.blockedAds": "",
"unifiedAds.spocs.enabled": true,
"discoverystream.placements.spocs": "newtab_stories_1",
"discoverystream.placements.spocs.counts": "1",
"unifiedAds.ohttp.enabled": true,
},
},
});
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue({
newtab_stories_1: [
{
format: "spoc",
title: "ad title",
},
],
});
await feed.loadSpocs(feed.store.dispatch);
expect(feed.fetchFromEndpoint.mock.calls[1][0]).toBe(
"unifiedAdEndpoint/v1/ads"
);
expect(feed.fetchFromEndpoint.mock.calls[1][1].body).toBe(
JSON.stringify({
context_id: "ContextId",
flags: {},
placements: [
{
placement: "newtab_stories_1",
count: 1,
},
],
blocks: [""],
})
);
});
it("should fetch ads with adsBackend flags", async () => {
feed.store = createStore(combineReducers(reducers), {
Prefs: {
values: {
"unifiedAds.endpoint": "unifiedAdEndpoint/",
"unifiedAds.blockedAds": "",
"unifiedAds.spocs.enabled": true,
"discoverystream.placements.spocs": "newtab_stories_1",
"discoverystream.placements.spocs.counts": "1",
"unifiedAds.ohttp.enabled": true,
adsBackendConfig: {
feature1: true,
feature2: false,
},
},
},
});
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue({
newtab_stories_1: [
{
format: "spoc",
title: "ad title",
},
],
});
await feed.loadSpocs(feed.store.dispatch);
expect(feed.fetchFromEndpoint.mock.calls[1][0]).toBe(
"unifiedAdEndpoint/v1/ads"
);
expect(feed.fetchFromEndpoint.mock.calls[1][1].body).toBe(
JSON.stringify({
context_id: "ContextId",
flags: {
feature1: true,
feature2: false,
},
placements: [
{
placement: "newtab_stories_1",
count: 1,
},
],
blocks: [""],
})
);
});
it("should use adsClient when enabled", async () => {
expectConsoleError();
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve());
jest.spyOn(feed.cache, "set").mockReturnValue(Promise.resolve());
feed.store = createStore(combineReducers(reducers), {
Prefs: {
values: {
"unifiedAds.blockedAds": "",
"unifiedAds.spocs.enabled": true,
"discoverystream.placements.spocs": "newtab_stories_1",
"discoverystream.placements.spocs.counts": "1",
},
},
});
const ADS_CLIENT = {
requestSpocAds: jest.fn().mockResolvedValue(
new Map([
[
"newtab_stories_1",
[
{
format: "spoc",
title: "Spoc 1",
domain: "spoc.example",
excerpt: "Excerpt",
sponsor: "Sponsor",
sponsoredByOverride: null,
blockKey: "spocblock1",
caps: { capKey: "cap1", day: 5 },
ranking: {
itemScore: 0.5,
personalizationModels: new Map([
["arts_and_entertainment", 1],
["travel", 1],
]),
priority: 2,
},
},
],
],
])
),
};
const REQUEST_OPTIONS = {
flags: new Map(),
ohttp: true,
};
const AdsClient = {
isEnabled: jest.fn().mockReturnValue(true),
getClient: jest.fn().mockReturnValue(ADS_CLIENT),
requestOptions: jest.fn().mockReturnValue(REQUEST_OPTIONS),
};
globalThis.AdsClient = AdsClient;
await feed.onAction({
type: at.INIT,
});
expect(AdsClient.isEnabled).toHaveBeenCalledTimes(1);
expect(AdsClient.getClient).toHaveBeenCalledTimes(1);
await feed.loadSpocs(feed.store.dispatch);
// The flags in adsBackendConfig only reach MARS if the prefs are passed.
expect(AdsClient.requestOptions).toHaveBeenCalledWith(
feed.store.getState().Prefs.values
);
expect(ADS_CLIENT.requestSpocAds).toHaveBeenCalledTimes(1);
expect(ADS_CLIENT.requestSpocAds).toHaveBeenCalledWith(
[expect.anything()],
REQUEST_OPTIONS
);
});
it("should not read the spocs cache when adsClient is set", async () => {
expectConsoleError();
jest.spyOn(feed.cache, "get").mockResolvedValue({
spocs: { lastUpdated: Date.now(), spocs: {} },
});
jest.spyOn(feed.cache, "set").mockResolvedValue();
feed.store = createStore(combineReducers(reducers), {
Prefs: {
values: {
"unifiedAds.blockedAds": "",
"unifiedAds.spocs.enabled": true,
"discoverystream.placements.spocs": "newtab_stories_1",
"discoverystream.placements.spocs.counts": "1",
},
},
});
const ADS_CLIENT = {
requestSpocAds: jest
.fn()
.mockResolvedValue(
new Map([["newtab_stories_1", [{ blockKey: "b1" }]]])
),
};
globalThis.AdsClient = {
isEnabled: jest.fn().mockReturnValue(true),
getClient: jest.fn().mockReturnValue(ADS_CLIENT),
requestOptions: jest.fn().mockReturnValue({}),
};
await feed.onAction({ type: at.INIT });
feed.cache.get.mockClear();
await feed.loadSpocs(feed.store.dispatch);
// A later, unrelated read happens while processing the results, so it is
// the ordering that shows the guarded read was skipped.
expect(ADS_CLIENT.requestSpocAds).toHaveBeenCalledTimes(1);
expect(
ADS_CLIENT.requestSpocAds.mock.invocationCallOrder[0]
).toBeLessThan(feed.cache.get.mock.invocationCallOrder[0]);
expect(feed.cache.set.mock.calls.some(([key]) => key === "spocs")).toBe(
false
);
// The same entry does suppress the fetch on the direct MARS path, which
// is what made it suppress the ads-client one too.
feed.adsClient = null;
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue({});
await feed.loadSpocs(feed.store.dispatch);
expect(feed.fetchFromEndpoint).not.toHaveBeenCalled();
});
it("should seed placements the ads client omitted with empty arrays", async () => {
feed.adsClient = {
requestSpocAds: jest
.fn()
.mockResolvedValue(new Map([["newtab_stories_1", []]])),
};
globalThis.AdsClient = {
isEnabled: jest.fn().mockReturnValue(true),
getClient: jest.fn().mockReturnValue(feed.adsClient),
requestOptions: jest.fn().mockReturnValue({}),
};
const result = await feed._fetchSpocsWithAdsClient([
{ placement: "newtab_stories_1", count: 1 },
{ placement: "newtab_stories_2", count: 1 },
]);
// loadSpocs concats by placement, so a missing key folds an undefined
// into the spocs list.
expect(result.newtab_stories_1).toEqual([]);
expect(result.newtab_stories_2).toEqual([]);
});
});
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);
expect(result).toEqual(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);
expect(result).toEqual({
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);
expect(result).toEqual({
title: "",
context: "",
sponsor: "",
sponsored_by_override: undefined,
items: [{ id: "id" }],
});
});
});
describe("#showSponsoredStories", () => {
it("should return false from showSponsoredStories if user pref showSponsored is false", async () => {
feed.store.getState = () => ({
Prefs: {
values: { showSponsored: false, "system.showSponsored": true },
},
});
expect(feed.showSponsoredStories).toBe(false);
});
it("should return false from showSponsoredStories if DiscoveryStream pref system.showSponsored is false", async () => {
feed.store.getState = () => ({
Prefs: {
values: { showSponsored: true, "system.showSponsored": false },
},
});
expect(feed.showSponsoredStories).toBe(false);
});
it("should return true from showSponsoredStories if both prefs are true", async () => {
feed.store.getState = () => ({
Prefs: {
values: { showSponsored: true, "system.showSponsored": true },
},
});
expect(feed.showSponsoredStories).toBe(true);
});
});
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,
},
},
});
expect(feed.showStories).toBe(false);
});
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,
},
},
});
expect(feed.showStories).toBe(false);
});
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,
},
},
});
expect(feed.showStories).toBe(true);
});
});
describe("#clearSpocs", () => {
let defaultState;
let DiscoveryStream;
let Prefs;
beforeEach(() => {
DiscoveryStream = {
layout: [],
};
Prefs = {
values: {
"feeds.section.topstories": true,
"feeds.system.topstories": true,
showSponsored: true,
"system.showSponsored": true,
},
};
defaultState = {
DiscoveryStream,
Prefs,
};
feed.store.getState = () => defaultState;
});
it("should not fail with no endpoint", async () => {
jest.spyOn(feed.store, "getState").mockReturnValue({
Prefs: {
values: { PREF_SPOCS_CLEAR_ENDPOINT: null },
},
});
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue(null);
await feed.clearSpocs();
expect(feed.fetchFromEndpoint).not.toHaveBeenCalled();
});
it("should call DELETE with endpoint", async () => {
jest.spyOn(feed.store, "getState").mockReturnValue({
},
});
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue(null);
feed._impressionId = "1234";
await feed.clearSpocs();
expect(feed.fetchFromEndpoint.mock.calls[0][0]).toBe(
);
expect(feed.fetchFromEndpoint.mock.calls[0][1].method).toBe("DELETE");
expect(feed.fetchFromEndpoint.mock.calls[0][1].body).toBe(
'{"pocket_id":"1234"}'
);
});
it("should properly call clearSpocs when sponsored content is changed", async () => {
jest.spyOn(feed, "clearSpocs").mockReturnValue(Promise.resolve());
jest.spyOn(feed, "loadSpocs").mockImplementation(() => {});
await feed.onAction({
type: at.PREF_CHANGED,
data: { name: "showSponsored" },
});
expect(feed.clearSpocs).not.toHaveBeenCalled();
Prefs.values.showSponsored = false;
await feed.onAction({
type: at.PREF_CHANGED,
data: { name: "showSponsored" },
});
expect(feed.clearSpocs).toHaveBeenCalledTimes(1);
});
it("should call clearSpocs when top stories are turned off", async () => {
jest.spyOn(feed, "clearSpocs").mockReturnValue(Promise.resolve());
Prefs.values["feeds.section.topstories"] = false;
await feed.onAction({
type: at.PREF_CHANGED,
data: { name: "feeds.section.topstories" },
});
expect(feed.clearSpocs).toHaveBeenCalledTimes(1);
});
});
describe("#rotate", () => {
it("should move seen first story to the back of the response", async () => {
const feedResponse = {
recommendations: [
{
id: "first",
},
{
id: "second",
},
{
id: "third",
},
{
id: "fourth",
},
],
};
const fakeImpressions = {
first: Date.now() - 60 * 60 * 1000, // 1 hour
third: Date.now(),
};
const cache = {
recsImpressions: fakeImpressions,
};
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve());
feed.cache.get.mockResolvedValue(cache);
const result = await feed.rotate(feedResponse.recommendations);
expect(result[3].id).toBe("first");
});
});
describe("#reset", () => {
it("should fire all reset based functions", async () => {
jest
.spyOn(globalThis.Services.obs, "removeObserver")
.mockImplementation(() => {});
jest.spyOn(feed, "resetDataPrefs").mockImplementation(() => {});
jest.spyOn(feed, "resetCache").mockReturnValue(Promise.resolve());
jest.spyOn(feed, "resetState").mockImplementation(() => {});
feed.loaded = true;
await feed.reset();
expect(feed.resetDataPrefs).toHaveBeenCalledTimes(1);
expect(feed.resetCache).toHaveBeenCalledTimes(1);
expect(feed.resetState).toHaveBeenCalledTimes(1);
});
});
describe("#resetCache", () => {
it("should set .feeds and .spocs and to {}", async () => {
jest.spyOn(feed.cache, "set").mockReturnValue(Promise.resolve());
await feed.resetCache();
expect(feed.cache.set).toHaveBeenCalledTimes(3);
const [firstCall, secondCall, thirdCall] = feed.cache.set.mock.calls;
expect(firstCall).toEqual(["feeds", {}]);
expect(secondCall).toEqual(["spocs", {}]);
expect(thirdCall).toEqual(["recsImpressions", {}]);
});
});
describe("#filterBlocked", () => {
it("should return initial data from filterBlocked if spocs are empty", async () => {
const { data: result } = await feed.filterBlocked([]);
expect(result.length).toBe(0);
});
it("should return initial data if links are not blocked", async () => {
const { data: result } = await feed.filterBlocked([
{ url: "test.com" },
]);
expect(result.length).toBe(2);
});
it("should return filtered data if links are blocked", async () => {
const fakeBlocks = {
flight_id_3: 1,
};
jest.spyOn(feed, "readDataPref").mockReturnValue(fakeBlocks);
jest
.spyOn(fakeNewTabUtils.blockedLinks, "isBlocked")
const cache = {
recsBlocks: {
id_4: 1,
},
};
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve());
jest.spyOn(feed.cache, "set").mockImplementation(() => {});
feed.cache.get.mockResolvedValue(cache);
const { data: result } = await feed.filterBlocked([
{
flight_id: "flight_id_1",
id: "id_1",
},
{
flight_id: "flight_id_2",
id: "id_2",
},
{
flight_id: "flight_id_3",
id: "id_3",
},
]);
expect(result.length).toBe(1);
});
it("filterRecommendations based on blockedlist by passing feed data", () => {
fakeNewTabUtils.blockedLinks.isBlocked = site =>
fakeNewTabUtils.blockedLinks.links[0].url === site.url;
const result = feed.filterRecommendations({
lastUpdated: 4,
data: {
},
});
expect(result.lastUpdated).toBe(4);
expect(result.data.recommendations).toHaveLength(1);
expect(result.data.recommendations[0].url).toBe("test.com");
expect(result.data.recommendations).not.toContain(
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],
};
jest.spyOn(feed, "readDataPref").mockReturnValue(fakeImpressions);
const { data: result, filtered } = feed.frequencyCapSpocs(fakeSpocs);
expect(result.length).toBe(1);
expect(result[0].flight_id).toBe("not-seen");
expect(filtered).toEqual([fakeSpocs[0]]);
});
it("should return simple structure and do nothing with no spocs", () => {
const { data: result, filtered } = feed.frequencyCapSpocs([]);
expect(result.length).toBe(0);
expect(filtered.length).toBe(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);
expect(result[0]).toEqual({
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);
expect(result[0]).toEqual({ id: 1 });
});
it("should return simple structure and do nothing with no spocs", () => {
const { data: result } = feed.migrateFlightId([]);
expect(result.length).toBe(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);
expect(result).toBe(true);
});
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);
expect(result).toBe(true);
});
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);
expect(result).toBe(false);
});
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);
expect(result).toBe(false);
});
});
describe("#retryFeed", () => {
it("should retry a feed fetch", async () => {
jest.spyOn(feed, "getComponentFeed").mockReturnValue(Promise.resolve({}));
jest.spyOn(feed.store, "dispatch");
expect(feed.getComponentFeed).toHaveBeenCalledTimes(1);
expect(feed.store.dispatch).toHaveBeenCalledTimes(1);
expect(feed.store.dispatch.mock.calls[0][0].type).toBe(
"DISCOVERY_STREAM_FEED_UPDATE"
);
expect(feed.store.dispatch.mock.calls[0][0].data).toEqual({
feed: {},
});
});
});
describe("#recordFlightImpression", () => {
it("should return false if time based cap is hit", () => {
jest.spyOn(feed, "readDataPref").mockReturnValue({});
jest.spyOn(feed, "writeDataPref").mockImplementation(() => {});
feed.recordFlightImpression("seen");
expect(feed.writeDataPref).toHaveBeenCalledWith(
SPOC_IMPRESSION_TRACKING_PREF,
{
seen: [0],
}
);
});
});
describe("#recordBlockFlightId", () => {
it("should call writeDataPref with new flight id added", () => {
jest.spyOn(feed, "readDataPref").mockReturnValue({ 1234: 1 });
jest.spyOn(feed, "writeDataPref").mockImplementation(() => {});
feed.recordBlockFlightId("5678");
expect(feed.readDataPref).toHaveBeenCalledTimes(1);
expect(feed.writeDataPref).toHaveBeenCalledWith(
"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],
};
jest.spyOn(feed, "getPlacements").mockReturnValue([{ name: "spocs" }]);
jest.spyOn(feed, "readDataPref").mockReturnValue(fakeImpressions);
jest.spyOn(feed, "writeDataPref").mockImplementation(() => {});
feed.cleanUpFlightImpressionPref(fakeSpocs);
expect(feed.writeDataPref).toHaveBeenCalledWith(
SPOC_IMPRESSION_TRACKING_PREF,
{
"flight-2": [-1],
}
);
});
});
describe("#recordTopRecImpression", () => {
it("should add a rec id to the rec impression pref", async () => {
const cache = {
recsImpressions: {},
};
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve());
jest.spyOn(feed.cache, "set").mockImplementation(() => {});
feed.cache.get.mockResolvedValue(cache);
await feed.recordTopRecImpression("rec");
expect(feed.cache.set).toHaveBeenCalledWith("recsImpressions", {
rec: 0,
});
});
it("should not add an impression if it already exists", async () => {
const cache = {
recsImpressions: { rec: 4 },
};
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve());
jest.spyOn(feed.cache, "set").mockImplementation(() => {});
feed.cache.get.mockResolvedValue(cache);
await feed.recordTopRecImpression("rec");
expect(feed.cache.set).not.toHaveBeenCalled();
});
});
describe("#cleanUpTopRecImpressions", () => {
it("should remove rec impressions older than 7 days", async () => {
const fakeImpressions = {
rec2: Date.now(),
rec3: Date.now(),
rec5: Date.now() - 7 * 24 * 60 * 60 * 1000, // 7 days
};
const cache = {
recsImpressions: fakeImpressions,
};
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve());
jest.spyOn(feed.cache, "set").mockImplementation(() => {});
feed.cache.get.mockResolvedValue(cache);
await feed.cleanUpTopRecImpressions();
expect(feed.cache.set).toHaveBeenCalledWith("recsImpressions", {
rec2: 0,
rec3: 0,
});
});
});
describe("#writeDataPref", () => {
it("should call Services.prefs.setStringPref", () => {
jest.spyOn(feed.store, "dispatch");
const fakeImpressions = {
foo: [Date.now() - 1],
bar: [Date.now() - 1],
};
feed.writeDataPref(SPOC_IMPRESSION_TRACKING_PREF, fakeImpressions);
expect(feed.store.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
data: {
name: SPOC_IMPRESSION_TRACKING_PREF,
value: JSON.stringify(fakeImpressions),
},
type: at.SET_PREF,
})
);
});
});
describe("#addEndpointQuery", () => {
it("should return same url with no query", () => {
const result = feed.addEndpointQuery(url, "");
expect(result).toBe(url);
});
it("should add multiple query params to standard url", () => {
const params = "?first=first&second=second";
const result = feed.addEndpointQuery(url, params);
expect(result).toBe(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}`
);
expect(result).toBe(`${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);
expect(result).toEqual(fakeImpressions);
});
});
describe("#setupPrefs", () => {
it("should call setupPrefs", async () => {
jest.spyOn(feed, "setupPrefs");
feed.onAction({
type: at.INIT,
});
expect(feed.setupPrefs).toHaveBeenCalledTimes(1);
});
it("should dispatch to at.DISCOVERY_STREAM_PREFS_SETUP with proper data", async () => {
jest.spyOn(feed.store, "dispatch");
jest
.spyOn(globalThis.NimbusFeatures.pocketNewtab, "getEnrollmentMetadata")
.mockReturnValue({
slug: "experimentId",
branch: "branchId",
isRollout: false,
});
feed.store.getState = () => ({
Prefs: {
values: {
region: "CA",
pocketConfig: {
hideDescriptions: false,
hideDescriptionsRegions: "US,CA,GB",
compactImages: true,
imageGradient: true,
newSponsoredLabel: true,
titleLines: "1",
descLines: "1",
readTime: true,
},
},
},
});
feed.setupPrefs();
expect(feed.store.dispatch.mock.calls[0][0].data).toEqual({
utmSource: "pocket-newtab",
utmCampaign: "experimentId",
utmContent: "branchId",
});
expect(feed.store.dispatch.mock.calls[1][0].data).toEqual({
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 () => {
jest.spyOn(feed, "recordTopRecImpression").mockImplementation(() => {});
await feed.onAction({
type: at.DISCOVERY_STREAM_IMPRESSION_STATS,
data: { tiles: [{ id: "seen" }] },
});
expect(feed.recordTopRecImpression).toHaveBeenCalledWith("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,
},
},
},
],
},
};
jest.spyOn(feed.store, "getState").mockReturnValue({
DiscoveryStream: {
spocs: {
data,
},
},
Prefs: {
values: {
trainhopConfig: {},
},
},
});
});
it("should call dispatch to ac.AlsoToPreloaded with filtered spoc data", async () => {
jest.spyOn(feed, "getPlacements").mockReturnValue([{ name: "spocs" }]);
Object.defineProperty(feed, "showSponsoredStories", { 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,
},
},
},
],
},
};
jest.spyOn(feed, "recordFlightImpression").mockImplementation(() => {});
jest.spyOn(feed, "readDataPref").mockReturnValue(fakeImpressions);
jest.spyOn(feed.store, "dispatch");
await feed.onAction({
type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
data: { flightId: "seen" },
});
expect(feed.store.dispatch.mock.calls[1][0].data.spocs).toEqual(result);
});
it("should not call dispatch to ac.AlsoToPreloaded if spocs were not changed by frequency capping", async () => {
jest.spyOn(feed, "getPlacements").mockReturnValue([{ name: "spocs" }]);
Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
const fakeImpressions = {};
jest.spyOn(feed, "recordFlightImpression").mockImplementation(() => {});
jest.spyOn(feed, "readDataPref").mockReturnValue(fakeImpressions);
jest.spyOn(feed.store, "dispatch");
await feed.onAction({
type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
data: { flight_id: "seen" },
});
expect(feed.store.dispatch).not.toHaveBeenCalled();
});
it("should attempt feq cap on valid spocs with placements on impression", async () => {
jest.restoreAllMocks();
Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
const fakeImpressions = {};
jest.spyOn(feed, "recordFlightImpression").mockImplementation(() => {});
jest.spyOn(feed, "readDataPref").mockReturnValue(fakeImpressions);
jest.spyOn(feed.store, "dispatch");
jest.spyOn(feed, "frequencyCapSpocs");
const data = {
spocs: {
items: [
{
id: 2,
flight_id: "seen-2",
caps: {
lifetime: 3,
flight: {
count: 1,
period: 1,
},
},
},
],
},
};
jest.spyOn(feed.store, "getState").mockReturnValue({
DiscoveryStream: {
spocs: {
data,
placements: [{ name: "spocs" }, { name: "notSpocs" }],
},
},
});
await feed.onAction({
type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
data: { flight_id: "doesn't matter" },
});
expect(feed.frequencyCapSpocs).toHaveBeenCalledTimes(1);
expect(feed.frequencyCapSpocs).toHaveBeenCalledWith(data.spocs.items);
});
});
describe("#onAction: PLACES_LINK_BLOCKED", () => {
beforeEach(() => {
const spocsData = {
data: {
spocs: {
items: [
{
id: 1,
flight_id: "foo",
url: "foo.com",
},
{
id: 2,
flight_id: "bar",
url: "bar.com",
},
],
},
},
placements: [{ name: "spocs" }],
};
const feedsData = {
data: {},
};
jest.spyOn(feed.store, "getState").mockReturnValue({
DiscoveryStream: {
spocs: spocsData,
feeds: feedsData,
},
});
});
it("should call dispatch if found a blocked spoc", async () => {
Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
Object.defineProperty(feed, "spocsOnDemand", { get: () => false });
Object.defineProperty(feed, "spocsCacheUpdateTime", {
get: () => 30 * 60 * 1000,
});
jest.spyOn(feed.store, "dispatch");
await feed.onAction({
type: at.PLACES_LINK_BLOCKED,
data: { url: "foo.com" },
});
expect(feed.store.dispatch.mock.calls[0][0].data.url).toEqual("foo.com");
});
it("should dispatch once if the blocked is not a SPOC", async () => {
Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
jest.spyOn(feed.store, "dispatch");
await feed.onAction({
type: at.PLACES_LINK_BLOCKED,
data: { url: "not_a_spoc.com" },
});
expect(feed.store.dispatch).toHaveBeenCalledTimes(1);
expect(feed.store.dispatch.mock.calls[0][0].data.url).toEqual(
"not_a_spoc.com"
);
});
it("should dispatch a DISCOVERY_STREAM_SPOC_BLOCKED for a blocked spoc", async () => {
Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
Object.defineProperty(feed, "spocsOnDemand", { get: () => false });
Object.defineProperty(feed, "spocsCacheUpdateTime", {
get: () => 30 * 60 * 1000,
});
jest.spyOn(feed.store, "dispatch");
await feed.onAction({
type: at.PLACES_LINK_BLOCKED,
data: { url: "foo.com" },
});
expect(feed.store.dispatch.mock.calls[1][0].type).toBe(
"DISCOVERY_STREAM_SPOC_BLOCKED"
);
});
});
describe("#onAction: BLOCK_URL", () => {
it("should call recordBlockFlightId whith BLOCK_URL", async () => {
jest.spyOn(feed, "recordBlockFlightId").mockImplementation(() => {});
await feed.onAction({
type: at.BLOCK_URL,
data: [
{
flight_id: "1234",
},
],
});
expect(feed.recordBlockFlightId).toHaveBeenCalledWith("1234");
});
});
describe("#onAction: INIT", () => {
it("should be .loaded=false before initialization", () => {
expect(feed.loaded).toBe(false);
});
it("should load data and set .loaded=true if config.enabled is true", async () => {
jest.spyOn(feed.cache, "set").mockReturnValue(Promise.resolve());
setPref(CONFIG_PREF_NAME, { enabled: true });
jest.spyOn(feed, "loadLayout").mockReturnValue(Promise.resolve());
await feed.onAction({ type: at.INIT });
expect(feed.loadLayout).toHaveBeenCalledTimes(1);
expect(feed.loaded).toBe(true);
});
});
describe("#onAction: DISCOVERY_STREAM_CONFIG_SET_VALUE", () => {
it("should add the new value to the pref without changing the existing values", async () => {
jest.spyOn(feed.store, "dispatch");
setPref(CONFIG_PREF_NAME, { enabled: true, other: "value" });
await feed.onAction({
type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE,
data: { name: "api_key_pref", value: "foo" },
});
expect(feed.store.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
data: {
name: CONFIG_PREF_NAME,
value: JSON.stringify({
enabled: true,
other: "value",
api_key_pref: "foo",
}),
},
type: at.SET_PREF,
})
);
});
});
describe("#onAction: DISCOVERY_STREAM_CONFIG_RESET", () => {
it("should call configReset", async () => {
jest.spyOn(feed, "configReset");
feed.onAction({
type: at.DISCOVERY_STREAM_CONFIG_RESET,
});
expect(feed.configReset).toHaveBeenCalledTimes(1);
});
});
describe("#onAction: DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", () => {
it("Should dispatch CLEAR_PREF with pref name", async () => {
jest.spyOn(feed.store, "dispatch");
await feed.onAction({
type: at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS,
});
expect(feed.store.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: CONFIG_PREF_NAME,
}),
type: at.CLEAR_PREF,
})
);
});
});
describe("#onAction: DISCOVERY_STREAM_RETRY_FEED", () => {
it("should call retryFeed", async () => {
expectConsoleError();
jest.spyOn(feed, "retryFeed");
feed.onAction({
type: at.DISCOVERY_STREAM_RETRY_FEED,
});
expect(feed.retryFeed).toHaveBeenCalledTimes(1);
});
});
describe("#onAction: DISCOVERY_STREAM_CONFIG_CHANGE", () => {
it("should call this.loadLayout if config.enabled changes to true ", async () => {
jest.spyOn(feed.cache, "set").mockReturnValue(Promise.resolve());
// First initialize
await feed.onAction({ type: at.INIT });
expect(feed.loaded).toBe(false);
// force clear cached pref value
feed._prefCache = {};
setPref(CONFIG_PREF_NAME, { enabled: true });
jest.spyOn(feed, "resetCache").mockReturnValue(Promise.resolve());
jest.spyOn(feed, "loadLayout").mockReturnValue(Promise.resolve());
await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
expect(feed.loadLayout).toHaveBeenCalledTimes(1);
expect(feed.resetCache).toHaveBeenCalledTimes(1);
expect(feed.loaded).toBe(true);
});
it("should clear the cache if a config change happens and config.enabled is true", async () => {
expectConsoleError();
jest.spyOn(feed.cache, "set").mockReturnValue(Promise.resolve());
// force clear cached pref value
feed._prefCache = {};
setPref(CONFIG_PREF_NAME, { enabled: true });
jest.spyOn(feed, "resetCache").mockReturnValue(Promise.resolve());
await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
expect(feed.resetCache).toHaveBeenCalledTimes(1);
});
it("should dispatch DISCOVERY_STREAM_LAYOUT_RESET from DISCOVERY_STREAM_CONFIG_CHANGE", async () => {
jest.spyOn(feed, "resetDataPrefs").mockImplementation(() => {});
jest.spyOn(feed, "resetCache").mockResolvedValue();
jest.spyOn(feed, "enable").mockResolvedValue();
setPref(CONFIG_PREF_NAME, { enabled: true });
jest.spyOn(feed.store, "dispatch");
await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
expect(feed.store.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: at.DISCOVERY_STREAM_LAYOUT_RESET,
})
);
});
it("should not call this.loadLayout if config.enabled changes to false", async () => {
expectConsoleError();
jest.spyOn(feed.cache, "set").mockReturnValue(Promise.resolve());
// force clear cached pref value
feed._prefCache = {};
setPref(CONFIG_PREF_NAME, { enabled: true });
await feed.onAction({ type: at.INIT });
expect(feed.loaded).toBe(true);
feed._prefCache = {};
setPref(CONFIG_PREF_NAME, { enabled: false });
jest.spyOn(feed, "resetCache").mockReturnValue(Promise.resolve());
jest.spyOn(feed, "loadLayout").mockReturnValue(Promise.resolve());
await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
expect(feed.loadLayout).not.toHaveBeenCalled();
expect(feed.resetCache).toHaveBeenCalledTimes(1);
expect(feed.loaded).toBe(false);
});
});
describe("#onAction: UNINIT", () => {
it("should reset pref cache", async () => {
feed._prefCache = { cached: "value" };
await feed.onAction({ type: at.UNINIT });
expect(feed._prefCache).toEqual({});
});
});
describe("#onAction: PREF_CHANGED", () => {
it("should update state.DiscoveryStream.config when the pref changes", async () => {
setPref(CONFIG_PREF_NAME, {
enabled: true,
api_key_pref: "foo",
});
expect(feed.store.getState().DiscoveryStream.config).toEqual({
enabled: true,
api_key_pref: "foo",
});
});
it("should fire loadSpocs is showSponsored pref changes", async () => {
jest.spyOn(feed, "loadSpocs").mockReturnValue(Promise.resolve());
await feed.onAction({
type: at.PREF_CHANGED,
data: { name: "showSponsored" },
});
expect(feed.loadSpocs).toHaveBeenCalledTimes(1);
});
it("should fire onPrefChange when pocketConfig pref changes", async () => {
jest.spyOn(feed, "onPrefChange").mockReturnValue(Promise.resolve());
await feed.onAction({
type: at.PREF_CHANGED,
data: { name: "pocketConfig", value: false },
});
expect(feed.onPrefChange).toHaveBeenCalledTimes(1);
});
it("should re enable stories when top stories is turned on", async () => {
jest.spyOn(feed, "refreshAll").mockReturnValue(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 },
});
expect(feed.refreshAll).toHaveBeenCalledTimes(1);
});
it("shoud update allowlist", async () => {
expect(feed.store.getState().Prefs.values[ENDPOINTS_PREF_NAME]).toBe(
DUMMY_ENDPOINT
);
setPref(ENDPOINTS_PREF_NAME, "sick-kickflip.mozilla.net");
expect(feed.store.getState().Prefs.values[ENDPOINTS_PREF_NAME]).toBe(
"sick-kickflip.mozilla.net"
);
});
});
describe("#onAction: SYSTEM_TICK", () => {
it("should not refresh if DiscoveryStream has not been loaded", async () => {
jest.spyOn(feed, "refreshAll").mockResolvedValue();
setPref(CONFIG_PREF_NAME, { enabled: true });
await feed.onAction({ type: at.SYSTEM_TICK });
expect(feed.refreshAll).not.toHaveBeenCalled();
});
it("should not refresh if no caches are expired", async () => {
expectConsoleError();
jest.spyOn(feed.cache, "set").mockResolvedValue();
setPref(CONFIG_PREF_NAME, { enabled: true });
await feed.onAction({ type: at.INIT });
jest.spyOn(feed, "onSystemTick").mockResolvedValue();
jest.spyOn(feed, "refreshAll").mockResolvedValue();
await feed.onAction({ type: at.SYSTEM_TICK });
expect(feed.refreshAll).not.toHaveBeenCalled();
});
it("should refresh if DiscoveryStream has been loaded at least once and a cache has expired", async () => {
expectConsoleError();
jest.spyOn(feed.cache, "set").mockResolvedValue();
setPref(CONFIG_PREF_NAME, { enabled: true });
await feed.onAction({ type: at.INIT });
jest.spyOn(feed, "refreshAll").mockResolvedValue();
await feed.onAction({ type: at.SYSTEM_TICK });
expect(feed.refreshAll).toHaveBeenCalledTimes(1);
});
it("should refresh and not update open tabs if DiscoveryStream has been loaded at least once", async () => {
expectConsoleError();
jest.spyOn(feed.cache, "set").mockResolvedValue();
setPref(CONFIG_PREF_NAME, { enabled: true });
await feed.onAction({ type: at.INIT });
jest.spyOn(feed, "refreshAll").mockResolvedValue();
await feed.onAction({ type: at.SYSTEM_TICK });
expect(feed.refreshAll).toHaveBeenCalledWith({
updateOpenTabs: false,
isSystemTick: true,
});
});
});
describe("#enable", () => {
it("should pass along proper options to refreshAll from enable", async () => {
jest.spyOn(feed, "refreshAll").mockImplementation(() => {});
await feed.enable();
expect(feed.refreshAll).toHaveBeenCalledWith({});
await feed.enable({ updateOpenTabs: true });
expect(feed.refreshAll).toHaveBeenCalledWith({ updateOpenTabs: true });
await feed.enable({ isStartup: true });
expect(feed.refreshAll).toHaveBeenCalledWith({ isStartup: true });
await feed.enable({ updateOpenTabs: true, isStartup: true });
expect(feed.refreshAll).toHaveBeenCalledWith({
updateOpenTabs: true,
isStartup: true,
});
});
});
describe("#onPrefChange", () => {
it("should call loadLayout when Pocket config changes", async () => {
jest.spyOn(feed, "loadLayout").mockImplementation(() => {});
feed._prefCache.config = {
enabled: true,
};
await feed.onPrefChange();
expect(feed.loadLayout).toHaveBeenCalledTimes(1);
});
it("should update open tabs but not startup with onPrefChange", async () => {
jest.spyOn(feed, "refreshAll").mockImplementation(() => {});
feed._prefCache.config = {
enabled: true,
};
await feed.onPrefChange();
expect(feed.refreshAll).toHaveBeenCalledWith({ updateOpenTabs: true });
});
});
describe("#onAction: PREF_SHOW_SPONSORED", () => {
it("should call loadSpocs when preference changes", async () => {
jest.spyOn(feed, "loadSpocs").mockResolvedValue();
jest.spyOn(feed.store, "dispatch").mockImplementation(() => {});
await feed.onAction({
type: at.PREF_CHANGED,
data: { name: "showSponsored" },
});
expect(feed.loadSpocs).toHaveBeenCalledTimes(1);
const [dispatchFn] = feed.loadSpocs.mock.lastCall;
dispatchFn({});
expect(feed.store.dispatch).toHaveBeenCalledWith(
ac.BroadcastToContent({})
);
});
});
describe("#onAction: DISCOVERY_STREAM_DEV_SYNC_RS", () => {
it("should fire remote settings pollChanges", async () => {
jest
.spyOn(globalThis.RemoteSettings, "pollChanges")
.mockImplementation(() => {});
await feed.onAction({
type: at.DISCOVERY_STREAM_DEV_SYNC_RS,
});
expect(globalThis.RemoteSettings.pollChanges).toHaveBeenCalledTimes(1);
});
});
describe("#onAction: DISCOVERY_STREAM_DEV_REFRESH_CACHE", () => {
it("should clear the cache with DISCOVERY_STREAM_DEV_REFRESH_CACHE", async () => {
jest.spyOn(feed.cache, "set").mockReturnValue(Promise.resolve());
jest.spyOn(feed, "resetCache").mockReturnValue(Promise.resolve());
await feed.onAction({ type: at.DISCOVERY_STREAM_DEV_REFRESH_CACHE });
expect(feed.resetCache).toHaveBeenCalledTimes(1);
});
});
describe("#onAction: DISCOVERY_STREAM_DEV_SYSTEM_TICK", () => {
it("should refresh if DiscoveryStream has been loaded at least once and a cache has expired", async () => {
expectConsoleError();
jest.spyOn(feed.cache, "set").mockResolvedValue();
setPref(CONFIG_PREF_NAME, { enabled: true });
await feed.onAction({ type: at.INIT });
jest.spyOn(feed, "refreshAll").mockResolvedValue();
await feed.onAction({ type: at.DISCOVERY_STREAM_DEV_SYSTEM_TICK });
expect(feed.refreshAll).toHaveBeenCalledTimes(1);
});
});
describe("#onAction: DISCOVERY_STREAM_DEV_EXPIRE_CACHE", () => {
it("should fire resetCache", async () => {
jest.spyOn(feed, "resetContentCache").mockImplementation(() => {});
await feed.onAction({
type: at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE,
});
expect(feed.resetContentCache).toHaveBeenCalledTimes(1);
});
});
describe("#spocsCacheUpdateTime", () => {
it("should return default cache time", () => {
const defaultCacheTime = 30 * 60 * 1000;
const cacheTime = feed.spocsCacheUpdateTime;
expect(feed._spocsCacheUpdateTime).toBe(defaultCacheTime);
expect(cacheTime).toBe(defaultCacheTime);
});
it("should return _spocsCacheUpdateTime", () => {
const testCacheTime = 123;
feed._spocsCacheUpdateTime = testCacheTime;
const cacheTime = feed.spocsCacheUpdateTime;
expect(feed._spocsCacheUpdateTime).toBe(testCacheTime);
expect(cacheTime).toBe(testCacheTime);
});
it("should set _spocsCacheUpdateTime with min", () => {
const defaultCacheTime = 30 * 60 * 1000;
feed.store.getState = () => ({
Prefs: {
values: {
"discoverystream.spocs.cacheTimeout": 1,
showSponsored: true,
"system.showSponsored": true,
},
},
});
const cacheTime = feed.spocsCacheUpdateTime;
expect(feed._spocsCacheUpdateTime).toBe(defaultCacheTime);
expect(cacheTime).toBe(defaultCacheTime);
});
it("should set _spocsCacheUpdateTime with max", () => {
const defaultCacheTime = 30 * 60 * 1000;
feed.store.getState = () => ({
Prefs: {
values: {
"discoverystream.spocs.cacheTimeout": 31,
showSponsored: true,
"system.showSponsored": true,
},
},
});
const cacheTime = feed.spocsCacheUpdateTime;
expect(feed._spocsCacheUpdateTime).toBe(defaultCacheTime);
expect(cacheTime).toBe(defaultCacheTime);
});
it("should set _spocsCacheUpdateTime with spocsCacheTimeout", () => {
const defaultCacheTime = 20 * 60 * 1000;
feed.store.getState = () => ({
Prefs: {
values: {
"discoverystream.spocs.cacheTimeout": 20,
showSponsored: true,
"system.showSponsored": true,
},
},
});
const cacheTime = feed.spocsCacheUpdateTime;
expect(feed._spocsCacheUpdateTime).toBe(defaultCacheTime);
expect(cacheTime).toBe(defaultCacheTime);
});
it("should set _spocsCacheUpdateTime with spocsCacheTimeout and onDemand", () => {
const defaultCacheTime = 4 * 60 * 1000;
feed.store.getState = () => ({
Prefs: {
values: {
"discoverystream.spocs.onDemand": true,
"discoverystream.spocs.cacheTimeout": 4,
showSponsored: true,
"system.showSponsored": true,
},
},
});
const cacheTime = feed.spocsCacheUpdateTime;
expect(feed._spocsCacheUpdateTime).toBe(defaultCacheTime);
expect(cacheTime).toBe(defaultCacheTime);
});
it("should set _spocsCacheUpdateTime with spocsCacheTimeout without max", () => {
const defaultCacheTime = 31 * 60 * 1000;
feed.store.getState = () => ({
Prefs: {
values: {
"discoverystream.spocs.onDemand": true,
"discoverystream.spocs.cacheTimeout": 31,
showSponsored: true,
"system.showSponsored": true,
},
},
});
const cacheTime = feed.spocsCacheUpdateTime;
expect(feed._spocsCacheUpdateTime).toBe(defaultCacheTime);
expect(cacheTime).toBe(defaultCacheTime);
});
it("should set _spocsCacheUpdateTime with spocsCacheTimeout without min", () => {
const defaultCacheTime = 1 * 60 * 1000;
feed.store.getState = () => ({
Prefs: {
values: {
"discoverystream.spocs.onDemand": true,
"discoverystream.spocs.cacheTimeout": 1,
showSponsored: true,
"system.showSponsored": true,
},
},
});
const cacheTime = feed.spocsCacheUpdateTime;
expect(feed._spocsCacheUpdateTime).toBe(defaultCacheTime);
expect(cacheTime).toBe(defaultCacheTime);
});
});
describe("#isExpired", () => {
it("should throw if the key is not valid", () => {
expect(() => {
feed.isExpired({}, "foo");
}).toThrow();
});
it("should return false for spocs on startup for content under 1 week", () => {
const spocs = { lastUpdated: Date.now() };
const result = feed.isExpired({
cachedData: { spocs },
key: "spocs",
isStartup: true,
});
expect(result).toBe(false);
});
it("should return true for spocs for isStartup=false after 30 mins", () => {
const spocs = { lastUpdated: Date.now() };
jest.advanceTimersByTime(THIRTY_MINUTES + 1);
const result = feed.isExpired({ cachedData: { spocs }, key: "spocs" });
expect(result).toBe(true);
});
it("should return true for spocs on startup for content over 1 week", () => {
const spocs = { lastUpdated: Date.now() };
jest.advanceTimersByTime(ONE_WEEK + 1);
const result = feed.isExpired({
cachedData: { spocs },
key: "spocs",
isStartup: true,
});
expect(result).toBe(true);
});
});
describe("#_checkExpirationPerComponent", () => {
let cache;
beforeEach(() => {
cache = {
feeds: { "foo.com": { lastUpdated: Date.now() } },
spocs: { lastUpdated: Date.now() },
};
Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
jest.spyOn(feed.cache, "get").mockResolvedValue(cache);
});
it("should return false if nothing in the cache is expired", async () => {
const results = await feed._checkExpirationPerComponent();
expect(results.spocs).toBe(false);
expect(results.feeds).toBe(false);
});
it("should return true if .spocs is missing", async () => {
delete cache.spocs;
const results = await feed._checkExpirationPerComponent();
expect(results.spocs).toBe(true);
expect(results.feeds).toBe(false);
});
it("should return true if .feeds is missing", async () => {
delete cache.feeds;
const results = await feed._checkExpirationPerComponent();
expect(results.spocs).toBe(false);
expect(results.feeds).toBe(true);
});
it("should return true if spocs are expired", async () => {
jest.advanceTimersByTime(THIRTY_MINUTES + 1);
// Update other caches we aren't testing
cache.feeds["foo.com"].lastUpdated = Date.now();
const results = await feed._checkExpirationPerComponent();
expect(results.spocs).toBe(true);
expect(results.feeds).toBe(false);
});
it("should return true if data for .feeds[url] is missing", async () => {
cache.feeds["foo.com"] = null;
const results = await feed._checkExpirationPerComponent();
expect(results.spocs).toBe(false);
expect(results.feeds).toBe(true);
});
it("should return true if data for .feeds[url] is expired", async () => {
jest.advanceTimersByTime(THIRTY_MINUTES + 1);
// Update other caches we aren't testing
cache.spocs.lastUpdated = Date.now();
const results = await feed._checkExpirationPerComponent();
expect(results.spocs).toBe(false);
expect(results.feeds).toBe(true);
});
});
describe("#refreshAll", () => {
beforeEach(() => {
jest.spyOn(feed, "loadLayout").mockResolvedValue();
jest.spyOn(feed, "loadComponentFeeds").mockResolvedValue();
jest.spyOn(feed, "loadSpocs").mockResolvedValue();
jest.spyOn(feed.store, "dispatch");
Object.defineProperty(feed, "showSponsoredStories", { get: () => true });
});
it("should call layout, component, spocs update and telemetry reporting functions", async () => {
await feed.refreshAll();
expect(feed.loadLayout).toHaveBeenCalledTimes(1);
expect(feed.loadComponentFeeds).toHaveBeenCalledTimes(1);
expect(feed.loadSpocs).toHaveBeenCalledTimes(1);
});
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 => {
expect(fn).toHaveBeenCalledTimes(1);
const result = fn.mock.calls[0][0]({ type: "FOO" });
expect(au.isBroadcastToContent(result)).toBe(true);
});
});
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 => {
expect(fn).toHaveBeenCalledTimes(1);
const result = fn.mock.calls[0][0]({ type: "FOO" });
expect(result).toEqual({ type: "FOO" });
});
});
it("should set loaded to true if loadSpocs and loadComponentFeeds fails", async () => {
expectConsoleError();
feed.loadComponentFeeds.mockRejectedValue("loadComponentFeeds error");
feed.loadSpocs.mockRejectedValue("loadSpocs error");
await feed.enable();
expect(feed.loaded).toBe(true);
});
it("should call loadComponentFeeds and loadSpocs in Promise.all", async () => {
jest.spyOn(Promise, "all").mockResolvedValue();
await feed.refreshAll();
expect(Promise.all).toHaveBeenCalledTimes(1);
const [args] = Promise.all.mock.lastCall;
expect(args.length).toBe(2);
});
describe("test startup cache behaviour", () => {
beforeEach(() => {
feed._maybeUpdateCachedData.mockRestore();
jest.spyOn(feed.cache, "set").mockResolvedValue();
});
it("should not refresh layout on startup if it is under THIRTY_MINUTES", async () => {
feed.loadLayout.mockRestore();
jest.spyOn(feed.cache, "get").mockResolvedValue({
layout: { lastUpdated: Date.now(), layout: {} },
});
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue({ layout: {} });
await feed.refreshAll({ isStartup: true });
expect(feed.fetchFromEndpoint).not.toHaveBeenCalled();
});
it("should refresh spocs on startup if it was served from cache", async () => {
expectConsoleError();
feed.loadSpocs.mockRestore();
jest.spyOn(feed, "getPlacements").mockReturnValue([{ name: "spocs" }]);
jest.spyOn(feed.cache, "get").mockResolvedValue({
spocs: { lastUpdated: Date.now() },
});
jest.advanceTimersByTime(THIRTY_MINUTES + 1);
await feed.refreshAll({ isStartup: true });
// Once from cache, once to update the store
expect(feed.store.dispatch).toHaveBeenCalledTimes(2);
expect(feed.store.dispatch.mock.calls[0][0].type).toBe(
at.DISCOVERY_STREAM_SPOCS_UPDATE
);
});
it("should not refresh spocs on startup if it is under THIRTY_MINUTES", async () => {
feed.loadSpocs.mockRestore();
jest.spyOn(feed.cache, "get").mockResolvedValue({
spocs: { lastUpdated: Date.now() },
});
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue("data");
await feed.refreshAll({ isStartup: true });
expect(feed.fetchFromEndpoint).not.toHaveBeenCalled();
});
it("should refresh feeds on startup if it was served from cache", async () => {
feed.loadComponentFeeds.mockRestore();
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,
},
},
};
jest.spyOn(feed.store, "getState").mockReturnValue(fakeDiscoveryStream);
jest.spyOn(feed, "rotate").mockImplementation(val => val);
jest
.spyOn(feed, "filterBlocked")
.mockImplementation(val => ({ data: val }));
const fakeCache = {
feeds: { "foo.com": { lastUpdated: Date.now(), data: ["data"] } },
};
jest.spyOn(feed.cache, "get").mockResolvedValue(fakeCache);
jest.advanceTimersByTime(THIRTY_MINUTES + 1);
stubOutFetchFromEndpointWithRealisticData();
await feed.refreshAll({ isStartup: true });
expect(feed.fetchFromEndpoint).toHaveBeenCalledTimes(1);
// Once from cache, once to update the feed, once to update that all
// feeds are done, and once to update scores.
expect(feed.store.dispatch).toHaveBeenCalledTimes(4);
expect(feed.store.dispatch.mock.calls[1][0].type).toBe(
at.DISCOVERY_STREAM_FEEDS_UPDATE
);
});
});
});
describe("#onAction: TOPIC_SELECTION_MAYBE_LATER", () => {
it("should call topicSelectionMaybeLaterEvent", async () => {
jest.spyOn(feed, "topicSelectionMaybeLaterEvent").mockResolvedValue();
await feed.onAction({
type: at.TOPIC_SELECTION_MAYBE_LATER,
});
expect(feed.topicSelectionMaybeLaterEvent).toHaveBeenCalledTimes(1);
});
});
describe("#topicSelectionMaybeLaterEvent", () => {
it("should use 3-day timeout for new profiles (age <= 1 day)", async () => {
jest.spyOn(feed, "retreiveProfileAge").mockResolvedValue(0.5);
jest.spyOn(feed.store, "dispatch");
await feed.topicSelectionMaybeLaterEvent();
const day = 24 * 60 * 60 * 1000;
expect(feed.store.dispatch).toHaveBeenCalledWith(
ac.SetPref(
"discoverystream.topicSelection.onboarding.displayTimeout",
3 * day
)
);
});
it("should use 7-day timeout for older profiles (age > 1 day)", async () => {
jest.spyOn(feed, "retreiveProfileAge").mockResolvedValue(5);
jest.spyOn(feed.store, "dispatch");
await feed.topicSelectionMaybeLaterEvent();
const day = 24 * 60 * 60 * 1000;
expect(feed.store.dispatch).toHaveBeenCalledWith(
ac.SetPref(
"discoverystream.topicSelection.onboarding.displayTimeout",
7 * day
)
);
});
});
describe("new proxy feed", () => {
beforeEach(() => {
globalThis.Region.home = "DE";
services.prefs.getStringPref.mockImplementation(name =>
name ===
"browser.newtabpage.activity-stream.discoverystream.merino-provider.endpoint"
? "merinoEndpoint"
: undefined
);
});
it("should update to new feed url", async () => {
await feed.loadLayout(feed.store.dispatch);
const { layout } = feed.store.getState().DiscoveryStream;
expect(layout[0].components[2].feed.url).toBe(
);
});
it("should fetch proper data from getComponentFeed", async () => {
const fakeCache = {};
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve(fakeCache));
jest.spyOn(feed, "rotate").mockImplementation(val => val);
jest.spyOn(feed, "scoreItemsInferred").mockImplementation(val => ({
data: val,
filtered: [],
personalized: false,
}));
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue({
recommendedAt: 1755834072383,
surfaceId: "NEW_TAB_EN_US",
data: [
{
corpusItemId: "decaf-c0ff33",
scheduledCorpusItemId: "matcha-latte-ff33c1",
excerpt: "excerpt",
iconUrl: "iconUrl",
imageUrl: "imageUrl",
isTimeSensitive: true,
publisher: "publisher",
receivedRank: 0,
tileId: 12345,
title: "title",
topic: "topic",
url: "url",
features: {},
},
],
});
const feedData = await feed.getComponentFeed("url");
const expectedData = {
lastUpdated: 0,
personalized: false,
sectionsEnabled: undefined,
data: {
settings: {},
sections: [],
interestPicker: {},
recommendations: [
{
id: "decaf-c0ff33",
corpus_item_id: "decaf-c0ff33",
scheduled_corpus_item_id: "matcha-latte-ff33c1",
excerpt: "excerpt",
icon_src: "iconUrl",
isTimeSensitive: true,
publisher: "publisher",
raw_image_src: "imageUrl",
received_rank: 0,
recommended_at: 1755834072383,
title: "title",
topic: "topic",
url: "url",
features: {},
},
],
surfaceId: "NEW_TAB_EN_US",
status: "success",
},
};
expect(feedData).toEqual(expectedData);
});
it("should fetch proper data from getComponentFeed with sections enabled", async () => {
setPref("discoverystream.sections.enabled", true);
const fakeCache = {};
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve(fakeCache));
jest.spyOn(feed, "rotate").mockImplementation(val => val);
jest.spyOn(feed, "scoreItemsInferred").mockImplementation(val => ({
data: val,
filtered: [],
personalized: false,
}));
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue({
recommendedAt: 1755834072383,
surfaceId: "NEW_TAB_EN_US",
data: [
{
corpusItemId: "decaf-c0ff33",
scheduledCorpusItemId: "matcha-latte-ff33c1",
excerpt: "excerpt",
iconUrl: "iconUrl",
imageUrl: "imageUrl",
isTimeSensitive: true,
publisher: "publisher",
receivedRank: 0,
tileId: 12345,
title: "title",
topic: "topic",
url: "url",
features: {},
},
],
feeds: {
"section-1": {
title: "Section 1",
subtitle: "Subtitle 1",
receivedFeedRank: 1,
layout: "cards",
iab: "iab-category",
isInitiallyVisible: true,
recommendations: [
{
corpusItemId: "decaf-c0ff34",
scheduledCorpusItemId: "matcha-latte-ff33c2",
excerpt: "section excerpt",
iconUrl: "sectionIconUrl",
imageUrl: "sectionImageUrl",
isTimeSensitive: false,
publisher: "section publisher",
serverScore: 0.9,
receivedRank: 1,
title: "section title",
topic: "section topic",
url: "section url",
features: {},
},
],
},
},
});
const feedData = await feed.getComponentFeed("url");
const expectedData = {
lastUpdated: 0,
personalized: false,
sectionsEnabled: true,
data: {
settings: {},
sections: [
{
sectionKey: "section-1",
title: "Section 1",
subtitle: "Subtitle 1",
receivedRank: 1,
layout: "cards",
iab: "iab-category",
allowAds: true,
followable: true,
visible: true,
},
],
interestPicker: {},
recommendations: [
{
id: "decaf-c0ff33",
scheduled_corpus_item_id: "matcha-latte-ff33c1",
corpus_item_id: "decaf-c0ff33",
features: {},
excerpt: "excerpt",
icon_src: "iconUrl",
isTimeSensitive: true,
publisher: "publisher",
raw_image_src: "imageUrl",
received_rank: 0,
recommended_at: 1755834072383,
title: "title",
topic: "topic",
url: "url",
},
{
id: "decaf-c0ff34",
scheduled_corpus_item_id: "matcha-latte-ff33c2",
corpus_item_id: "decaf-c0ff34",
url: "section url",
title: "section title",
topic: "section topic",
features: {},
excerpt: "section excerpt",
publisher: "section publisher",
raw_image_src: "sectionImageUrl",
received_rank: 1,
server_score: 0.9,
recommended_at: 1755834072383,
section: "section-1",
variant_id: 0,
icon_src: "sectionIconUrl",
isTimeSensitive: false,
},
],
surfaceId: "NEW_TAB_EN_US",
status: "success",
},
};
// Use JSON comparison because deepEqual will error with incorrect property order message
expect(JSON.stringify(feedData)).toBe(JSON.stringify(expectedData));
});
it("should include allowAds and followable in section objects", async () => {
setPref("discoverystream.sections.enabled", true);
const fakeCache = {};
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve(fakeCache));
jest.spyOn(feed, "rotate").mockImplementation(val => val);
jest.spyOn(feed, "scoreItemsInferred").mockImplementation(val => ({
data: val,
filtered: [],
personalized: false,
}));
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue({
recommendedAt: 0,
surfaceId: "NEW_TAB_EN_US",
data: [],
feeds: {
"section-1": {
title: "Section 1",
subtitle: "",
receivedFeedRank: 1,
layout: "cards",
iab: null,
isInitiallyVisible: true,
allowAds: false,
followable: true,
recommendations: [],
},
},
});
const feedData = await feed.getComponentFeed("url");
const [section] = feedData.data.sections;
expect(section.allowAds).toBe(false);
expect(section.followable).toBe(true);
});
it("should default allowAds and followable to true when absent", async () => {
setPref("discoverystream.sections.enabled", true);
const fakeCache = {};
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve(fakeCache));
jest.spyOn(feed, "rotate").mockImplementation(val => val);
jest.spyOn(feed, "scoreItemsInferred").mockImplementation(val => ({
data: val,
filtered: [],
personalized: false,
}));
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue({
recommendedAt: 0,
surfaceId: "NEW_TAB_EN_US",
data: [],
feeds: {
"section-1": {
title: "Section 1",
subtitle: "",
receivedFeedRank: 1,
layout: "cards",
iab: null,
isInitiallyVisible: true,
recommendations: [],
},
},
});
const feedData = await feed.getComponentFeed("url");
const [section] = feedData.data.sections;
expect(section.allowAds).toBe(true);
expect(section.followable).toBe(true);
});
it("should default allowAds and followable to true when null", async () => {
setPref("discoverystream.sections.enabled", true);
const fakeCache = {};
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve(fakeCache));
jest.spyOn(feed, "rotate").mockImplementation(val => val);
jest.spyOn(feed, "scoreItemsInferred").mockImplementation(val => ({
data: val,
filtered: [],
personalized: false,
}));
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue({
recommendedAt: 0,
surfaceId: "NEW_TAB_EN_US",
data: [],
feeds: {
"section-1": {
title: "Section 1",
subtitle: "",
receivedFeedRank: 1,
layout: "cards",
iab: null,
isInitiallyVisible: true,
allowAds: null,
followable: null,
recommendations: [],
},
},
});
const feedData = await feed.getComponentFeed("url");
const [section] = feedData.data.sections;
expect(section.allowAds).toBe(true);
expect(section.followable).toBe(true);
});
it("should include followable in interestPicker.sections", async () => {
setPref("discoverystream.sections.enabled", true);
const fakeCache = {};
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve(fakeCache));
jest.spyOn(feed, "rotate").mockImplementation(val => val);
jest.spyOn(feed, "scoreItemsInferred").mockImplementation(val => ({
data: val,
filtered: [],
personalized: false,
}));
jest.spyOn(feed, "fetchFromEndpoint").mockResolvedValue({
recommendedAt: 0,
surfaceId: "NEW_TAB_EN_US",
data: [],
feeds: {
"section-1": {
title: "Section 1",
subtitle: "",
receivedFeedRank: 1,
layout: "cards",
iab: null,
isInitiallyVisible: true,
followable: false,
recommendations: [],
},
},
interestPicker: {
sections: [{ sectionId: "section-1" }],
},
});
const feedData = await feed.getComponentFeed("url");
const [pickerSection] = feedData.data.interestPicker.sections;
expect(pickerSection.sectionId).toBe("section-1");
expect(pickerSection.title).toBe("Section 1");
expect(pickerSection.followable).toBe(false);
});
describe("section layouts", () => {
const CONFIGS = {
"custom-a": { name: "custom-a" },
"custom-b": { name: "custom-b" },
"custom-c": { name: "custom-c" },
};
const buildFeeds = defs =>
Object.fromEntries(
defs.map((def, i) => [
`section-${i}`,
{
title: `Section ${i}`,
subtitle: `Subtitle ${i}`,
receivedFeedRank: def.rank,
layout: def.layout,
allowAds: def.allowAds,
iab: "iab-category",
isInitiallyVisible: true,
recommendations: [],
},
])
);
const setFeeds = (defs, extra = {}) =>
feed.fetchFromEndpoint.mockResolvedValue({
recommendedAt: 1755834072383,
surfaceId: "NEW_TAB_EN_US",
data: [],
feeds: buildFeeds(defs),
...extra,
});
const dispatchLayouts = (configs, orderings) =>
feed.store.dispatch({
type: at.SECTIONS_LAYOUT_UPDATE,
data: { configs, orderings },
});
const layoutNames = async () => {
const feedData = await feed.getComponentFeed("url");
return feedData.data.sections.map(section => section.layout?.name);
};
let restoreLayoutGlobals;
beforeEach(() => {
setPref("discoverystream.sections.enabled", true);
restoreLayoutGlobals = stubGlobals({
SectionsLayoutManager,
maskLayoutAds,
});
dispatchLayouts(CONFIGS, {});
jest.spyOn(feed.cache, "get").mockReturnValue(Promise.resolve({}));
jest.spyOn(feed, "rotate").mockImplementation(val => val);
jest.spyOn(feed, "fetchFromEndpoint").mockImplementation(() => {});
setFeeds([
{ rank: 1, layout: { name: "original-layout" } },
{ rank: 2, layout: { name: "another-layout" } },
]);
});
afterEach(() => {
restoreLayoutGlobals();
});
it("keeps Merino layouts when nothing overrides them", async () => {
expect(await layoutNames()).toEqual([
"original-layout",
"another-layout",
]);
});
it("forces the default layout when clientLayout.enabled is true", async () => {
setPref("discoverystream.sections.clientLayout.enabled", true);
expect(await layoutNames()).toEqual([
"7-double-row-2-ad",
"6-small-medium-1-ad",
]);
});
it("forces the default layout for the whole page when a section is missing its layout", async () => {
setFeeds([
{ rank: 1, layout: undefined },
{ rank: 2, layout: { name: "another-layout" } },
]);
expect(await layoutNames()).toEqual([
"7-double-row-2-ad",
"6-small-medium-1-ad",
]);
});
it("does not throw when there are no sections", async () => {
setFeeds([]);
const feedData = await feed.getComponentFeed("url");
expect(feedData.data.sections).toEqual([]);
});
describe("RS sections ordering", () => {
const withServerLayout = defs =>
defs.map(def => ({ layout: { name: "server" }, ...def }));
const selectOrdering = (key, names) => {
dispatchLayouts(CONFIGS, { [key]: names });
setPref("discoverystream.sections.ordering", key);
};
it("ignores the ordering when the key pref is unset", async () => {
dispatchLayouts(CONFIGS, { "my-order": ["custom-a"] });
expect(await layoutNames()).toEqual([
"original-layout",
"another-layout",
]);
});
it("falls back to Merino when an ordering names an unknown layout", async () => {
selectOrdering("my-order", ["does-not-exist"]);
expect(await layoutNames()).toEqual([
"original-layout",
"another-layout",
]);
});
it("falls back to Merino when the ordering key has no matching record", async () => {
dispatchLayouts(CONFIGS, {});
setPref("discoverystream.sections.ordering", "not-published-yet");
expect(await layoutNames()).toEqual([
"original-layout",
"another-layout",
]);
});
it("ignores the Remote Settings ordering when clientLayout.enabled is set", async () => {
selectOrdering("my-order", ["custom-a", "custom-b"]);
setPref("discoverystream.sections.clientLayout.enabled", true);
setFeeds(withServerLayout([{ rank: 0 }, { rank: 1 }]));
expect(await layoutNames()).toEqual([
"7-double-row-2-ad",
"6-small-medium-1-ad",
]);
});
it("cycles a multi-entry ordering with position 0 pinned", async () => {
selectOrdering("my-order", ["custom-a", "custom-b", "custom-c"]);
setFeeds(
withServerLayout([
{ rank: 0 },
{ rank: 1 },
{ rank: 2 },
{ rank: 4 },
{ rank: 6 },
])
);
expect(await layoutNames()).toEqual([
"custom-a",
"custom-b",
"custom-c",
"custom-b",
"custom-c",
]);
});
it("pins position 0 and fills the rest from the default cycle for a single-entry ordering", async () => {
selectOrdering("my-order", ["custom-a"]);
setFeeds(
withServerLayout([
{ rank: 0 },
{ rank: 1 },
{ rank: 2 },
{ rank: 4 },
])
);
expect(await layoutNames()).toEqual([
"custom-a",
"6-small-medium-1-ad",
"4-large-small-medium-1-ad",
"4-medium-small-1-ad",
]);
});
it("masks ads based on the section's rank and allowAds", async () => {
const adLayout = name => ({
name,
responsiveLayouts: [
{ columnCount: 1, tiles: [{ position: 0, hasAd: true }] },
],
});
dispatchLayouts(
{ "ad-a": adLayout("ad-a"), "ad-b": adLayout("ad-b") },
{ "my-order": ["ad-a", "ad-b"] }
);
setPref("discoverystream.sections.ordering", "my-order");
setFeeds([
{ rank: 0, layout: { name: "server" }, allowAds: true },
{ rank: 1, layout: { name: "server" }, allowAds: false },
{ rank: 3, layout: { name: "server" }, allowAds: true },
{ rank: 20, layout: { name: "server" }, allowAds: true },
]);
const feedData = await feed.getComponentFeed("url");
const hasAd = section =>
section.layout.responsiveLayouts[0].tiles[0].hasAd;
const [rank0, rank1, rank3, rank20] = feedData.data.sections;
expect(hasAd(rank0)).toBe(true); // "ad kept for an allowed rank when allowAds is true";
expect(hasAd(rank1)).toBe(false); // "ad cleared when allowAds is false";
expect(hasAd(rank3)).toBe(false); // "ad cleared when the rank is not in the allowed set even though allowAds is true";
expect(hasAd(rank20)).toBe(false); // "ad cleared when the rank is past the allowed set";
});
it("lets a trainhopConfig override replace the default ad-rank set", async () => {
const adLayout = name => ({
name,
responsiveLayouts: [
{ columnCount: 1, tiles: [{ position: 0, hasAd: true }] },
],
});
dispatchLayouts(
{ "ad-a": adLayout("ad-a"), "ad-b": adLayout("ad-b") },
{ "my-order": ["ad-a", "ad-b"] }
);
setPref("discoverystream.sections.ordering", "my-order");
feed.store.dispatch({
type: at.PREF_CHANGED,
data: {
name: "trainhopConfig",
value: { sections: { adAllowedRanks: [3] } },
},
});
setFeeds([
{ rank: 0, layout: { name: "server" }, allowAds: true },
{ rank: 3, layout: { name: "server" }, allowAds: true },
]);
const feedData = await feed.getComponentFeed("url");
const hasAd = section =>
section.layout.responsiveLayouts[0].tiles[0].hasAd;
const [rank0, rank3] = feedData.data.sections;
expect(hasAd(rank0)).toBe(false); // "rank 0 masked — default-allowed but excluded by the override";
expect(hasAd(rank3)).toBe(true); // "rank 3 kept — default-disallowed but included by the override";
});
});
it("resolves the ad-allowed ranks pref as comma-separated ranks", () => {
setPref("discoverystream.sections.adAllowedRanks", "0, 2, 4");
expect([...feed.sectionsAdAllowedRanks]).toEqual([0, 2, 4]);
});
it("falls back to the default set when the pref is malformed", () => {
setPref("discoverystream.sections.adAllowedRanks", "0, x, 2");
expect([...feed.sectionsAdAllowedRanks]).toEqual([
...SectionsLayoutManager.AD_ALLOWED_RANKS,
]);
});
});
});
describe("#getContextualAdsPlacements", () => {
let prefs;
let feedsData;
let expected;
beforeEach(() => {
prefs = {
"discoverystream.placements.contextualSpocs":
"newtab_stories_1, newtab_stories_2, newtab_stories_3, newtab_stories_4, newtab_stories_5, newtab_stories_6",
"discoverystream.placements.contextualSpocs.counts": "1, 1, 1, 1, 1, 1",
"discoverystream.placements.contextualBanners": "",
"discoverystream.placements.contextualBanners.counts": "",
"newtabAdSize.leaderboard": false,
"newtabAdSize.billboard": false,
"newtabAdSize.leaderboard.position": 3,
"newtabAdSize.billboard.position": 3,
};
feedsData = {
data: {
sections: [
{
receivedRank: 0,
layout: {
responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
},
},
{
iab: { taxonomy: "IAB-3.0", categories: ["386"] },
receivedRank: 1,
layout: {
responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
},
},
{
iab: { taxonomy: "IAB-3.0", categories: ["52"] },
receivedRank: 2,
layout: {
responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
},
},
{
receivedRank: 3,
layout: {
responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
},
},
{
iab: { taxonomy: "IAB-3.0", categories: ["464"] },
receivedRank: 4,
layout: {
responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
},
},
{
receivedRank: 5,
layout: {
responsiveLayouts: [{ tiles: [{ hasAd: true }] }],
},
},
],
},
},
};
expected = [
{
placement: "newtab_stories_1",
count: 1,
},
{
placement: "newtab_stories_2",
count: 1,
content: {
taxonomy: "IAB-3.0",
categories: ["386"],
},
},
{
placement: "newtab_stories_3",
count: 1,
content: {
taxonomy: "IAB-3.0",
categories: ["52"],
},
},
{
placement: "newtab_stories_4",
count: 1,
},
{
placement: "newtab_stories_5",
count: 1,
content: {
taxonomy: "IAB-3.0",
categories: ["464"],
},
},
{
placement: "newtab_stories_6",
count: 1,
},
];
});
it("should only return SPOC placements", async () => {
feed.store.getState = () => ({
Prefs: {
values: prefs,
},
DiscoveryStream: {
feeds: {
data: feedsData,
},
},
});
const placements = feed.getContextualAdsPlacements();
expect(placements).toEqual(expected);
});
it("should return SPOC placements AND banner placements when leaderboard is enabled", async () => {
// Updating the prefs object keys to have the banner values ready for the test
prefs["discoverystream.placements.contextualBanners"] =
"newtab_leaderboard";
prefs["discoverystream.placements.contextualBanners.counts"] = "1";
prefs["newtabAdSize.leaderboard"] = true;
prefs["newtabAdSize.leaderboard.position"] = 2;
feed.store.getState = () => ({
Prefs: {
values: prefs,
},
DiscoveryStream: {
feeds: {
data: feedsData,
},
},
});
let placements = feed.getContextualAdsPlacements();
expect(placements).toEqual([
...expected,
...[
{
placement: "newtab_leaderboard",
count: 1,
},
],
]);
prefs["newtabAdSize.leaderboard.position"] = 3;
feed.store.getState = () => ({
Prefs: {
values: prefs,
},
DiscoveryStream: {
feeds: {
data: feedsData,
},
},
});
placements = feed.getContextualAdsPlacements();
expect(placements).toEqual([
...expected,
...[
{
placement: "newtab_leaderboard",
count: 1,
content: {
taxonomy: "IAB-3.0",
categories: ["386"],
},
},
],
]);
});
it("should return SPOC placements AND banner placements when billboard is enabled", async () => {
// Updating the prefs object keys to have the banner values ready for the test
prefs["discoverystream.placements.contextualBanners"] =
"newtab_billboard";
prefs["discoverystream.placements.contextualBanners.counts"] = "1";
prefs["newtabAdSize.billboard"] = true;
prefs["newtabAdSize.billboard.position"] = 2;
feed.store.getState = () => ({
Prefs: {
values: prefs,
},
DiscoveryStream: {
feeds: {
data: feedsData,
},
},
});
let placements = feed.getContextualAdsPlacements();
expect(placements).toEqual([
...expected,
...[
{
placement: "newtab_billboard",
count: 1,
},
],
]);
prefs["newtabAdSize.billboard.position"] = 3;
feed.store.getState = () => ({
Prefs: {
values: prefs,
},
DiscoveryStream: {
feeds: {
data: feedsData,
},
},
});
placements = feed.getContextualAdsPlacements();
expect(placements).toEqual([
...expected,
...[
{
placement: "newtab_billboard",
count: 1,
content: {
taxonomy: "IAB-3.0",
categories: ["386"],
},
},
],
]);
});
});
});