TopStoriesFeed.test.js 67 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606
  1. import {FakePrefs, GlobalOverrider} from "test/unit/utils";
  2. import {actionTypes as at} from "common/Actions.jsm";
  3. import injector from "inject!lib/TopStoriesFeed.jsm";
  4. describe("Top Stories Feed", () => {
  5. let TopStoriesFeed;
  6. let STORIES_UPDATE_TIME;
  7. let TOPICS_UPDATE_TIME;
  8. let SECTION_ID;
  9. let SPOC_IMPRESSION_TRACKING_PREF;
  10. let REC_IMPRESSION_TRACKING_PREF;
  11. let MIN_DOMAIN_AFFINITIES_UPDATE_TIME;
  12. let DEFAULT_RECS_EXPIRE_TIME;
  13. let instance;
  14. let clock;
  15. let globals;
  16. let sectionsManagerStub;
  17. let shortURLStub;
  18. const FAKE_OPTIONS = {
  19. "stories_endpoint": "https://somedomain.org/stories?key=$apiKey",
  20. "stories_referrer": "https://somedomain.org/referrer",
  21. "topics_endpoint": "https://somedomain.org/topics?key=$apiKey",
  22. "survey_link": "https://www.surveymonkey.com/r/newtabffx",
  23. "api_key_pref": "apiKeyPref",
  24. "provider_name": "test-provider",
  25. "provider_icon": "provider-icon",
  26. "provider_description": "provider_desc",
  27. };
  28. beforeEach(() => {
  29. FakePrefs.prototype.prefs.apiKeyPref = "test-api-key";
  30. FakePrefs.prototype.prefs.pocketCta = JSON.stringify({
  31. cta_button: "",
  32. cta_text: "",
  33. cta_url: "",
  34. use_cta: false,
  35. });
  36. globals = new GlobalOverrider();
  37. globals.set("PlacesUtils", {history: {}});
  38. globals.set("pktApi", {isUserLoggedIn() {}});
  39. clock = sinon.useFakeTimers();
  40. shortURLStub = sinon.stub().callsFake(site => site.url);
  41. sectionsManagerStub = {
  42. onceInitialized: sinon.stub().callsFake(callback => callback()),
  43. enableSection: sinon.spy(),
  44. disableSection: sinon.spy(),
  45. updateSection: sinon.spy(),
  46. sections: new Map([["topstories", {options: FAKE_OPTIONS}]]),
  47. };
  48. class FakeUserDomainAffinityProvider {
  49. constructor(timeSegments, parameterSets, maxHistoryQueryResults, version, scores) {
  50. this.timeSegments = timeSegments;
  51. this.parameterSets = parameterSets;
  52. this.maxHistoryQueryResults = maxHistoryQueryResults;
  53. this.version = version;
  54. this.scores = scores;
  55. }
  56. getAffinities() {
  57. return {};
  58. }
  59. }
  60. class FakePersonalityProvider extends FakeUserDomainAffinityProvider {}
  61. ({
  62. TopStoriesFeed,
  63. STORIES_UPDATE_TIME,
  64. TOPICS_UPDATE_TIME,
  65. SECTION_ID,
  66. SPOC_IMPRESSION_TRACKING_PREF,
  67. REC_IMPRESSION_TRACKING_PREF,
  68. MIN_DOMAIN_AFFINITIES_UPDATE_TIME,
  69. DEFAULT_RECS_EXPIRE_TIME,
  70. } = injector({
  71. "lib/ActivityStreamPrefs.jsm": {Prefs: FakePrefs},
  72. "lib/ShortURL.jsm": {shortURL: shortURLStub},
  73. "lib/PersonalityProvider.jsm": {PersonalityProvider: FakePersonalityProvider},
  74. "lib/UserDomainAffinityProvider.jsm": {UserDomainAffinityProvider: FakeUserDomainAffinityProvider},
  75. "lib/SectionsManager.jsm": {SectionsManager: sectionsManagerStub},
  76. }));
  77. instance = new TopStoriesFeed();
  78. instance.store = {getState() { return {Prefs: {values: {showSponsored: true}}}; }, dispatch: sinon.spy()};
  79. instance.storiesLastUpdated = 0;
  80. instance.topicsLastUpdated = 0;
  81. });
  82. afterEach(() => {
  83. globals.restore();
  84. clock.restore();
  85. });
  86. describe("#lazyloading TopStories", () => {
  87. beforeEach(() => {
  88. instance.discoveryStreamEnabled = true;
  89. });
  90. it("should bind parseOptions to SectionsManager.onceInitialized when discovery stream is true", () => {
  91. instance.discoveryStreamEnabled = false;
  92. instance.onAction({type: at.PREFS_INITIAL_VALUES, data: {"discoverystream.config": JSON.stringify({enabled: true})}});
  93. assert.calledOnce(sectionsManagerStub.onceInitialized);
  94. });
  95. it("should bind parseOptions to SectionsManager.onceInitialized when discovery stream is false", () => {
  96. instance.onAction({type: at.PREFS_INITIAL_VALUES, data: {"discoverystream.config": JSON.stringify({enabled: false})}});
  97. assert.calledOnce(sectionsManagerStub.onceInitialized);
  98. });
  99. it("Should initialize properties once while lazy loading if not initialized earlier", () => {
  100. instance.discoveryStreamEnabled = false;
  101. instance.propertiesInitialized = false;
  102. sinon.stub(instance, "initializeProperties");
  103. instance.lazyLoadTopStories();
  104. assert.calledOnce(instance.initializeProperties);
  105. });
  106. it("should not re-initialize properties", () => {
  107. // For discovery stream experience disabled TopStoriesFeed properties
  108. // are initialized in constructor and should not be called again while lazy loading topstories
  109. sinon.stub(instance, "initializeProperties");
  110. instance.discoveryStreamEnabled = false;
  111. instance.propertiesInitialized = true;
  112. instance.lazyLoadTopStories();
  113. assert.notCalled(instance.initializeProperties);
  114. });
  115. it("should have early exit onInit when discovery is true", async () => {
  116. sinon.stub(instance, "doContentUpdate");
  117. await instance.onInit();
  118. assert.notCalled(instance.doContentUpdate);
  119. assert.isUndefined(instance.storiesLoaded);
  120. });
  121. it("should complete onInit when discovery is false", async () => {
  122. instance.discoveryStreamEnabled = false;
  123. sinon.stub(instance, "doContentUpdate");
  124. await instance.onInit();
  125. assert.calledOnce(instance.doContentUpdate);
  126. assert.isTrue(instance.storiesLoaded);
  127. });
  128. it("should handle limited actions when discoverystream is enabled", async () => {
  129. sinon.spy(instance, "handleDisabled");
  130. sinon.stub(instance, "getPocketState");
  131. instance.onAction({type: at.PREFS_INITIAL_VALUES, data: {"discoverystream.config": JSON.stringify({enabled: true})}});
  132. assert.calledOnce(instance.handleDisabled);
  133. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  134. assert.notCalled(instance.getPocketState);
  135. });
  136. it("should handle NEW_TAB_REHYDRATED when discoverystream is disabled", async () => {
  137. instance.discoveryStreamEnabled = false;
  138. sinon.spy(instance, "handleDisabled");
  139. sinon.stub(instance, "getPocketState");
  140. instance.onAction({type: at.PREFS_INITIAL_VALUES, data: {"discoverystream.config": JSON.stringify({enabled: false})}});
  141. assert.notCalled(instance.handleDisabled);
  142. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  143. assert.calledOnce(instance.getPocketState);
  144. });
  145. it("should handle UNINIT when discoverystream is enabled", async () => {
  146. sinon.stub(instance, "uninit");
  147. instance.onAction({type: at.UNINIT});
  148. assert.calledOnce(instance.uninit);
  149. });
  150. it("should fire init on PREF_CHANGED", () => {
  151. sinon.stub(instance, "onInit");
  152. instance.onAction({type: at.PREF_CHANGED, data: {name: "discoverystream.config", value: {}}});
  153. assert.calledOnce(instance.onInit);
  154. });
  155. it("should not fire init on PREF_CHANGED if stories are loaded", () => {
  156. sinon.stub(instance, "onInit");
  157. sinon.spy(instance, "lazyLoadTopStories");
  158. instance.storiesLoaded = true;
  159. instance.onAction({type: at.PREF_CHANGED, data: {name: "discoverystream.config", value: {}}});
  160. assert.calledOnce(instance.lazyLoadTopStories);
  161. assert.notCalled(instance.onInit);
  162. });
  163. it("should fire init on PREF_CHANGED when discoverystream is disabled", () => {
  164. instance.discoveryStreamEnabled = false;
  165. sinon.stub(instance, "onInit");
  166. instance.onAction({type: at.PREF_CHANGED, data: {name: "discoverystream.config", value: {}}});
  167. assert.calledOnce(instance.onInit);
  168. });
  169. it("should not fire init on PREF_CHANGED when discoverystream is disabled and stories are loaded", () => {
  170. instance.discoveryStreamEnabled = false;
  171. sinon.stub(instance, "onInit");
  172. sinon.spy(instance, "lazyLoadTopStories");
  173. instance.storiesLoaded = true;
  174. instance.onAction({type: at.PREF_CHANGED, data: {name: "discoverystream.config", value: {}}});
  175. assert.calledOnce(instance.lazyLoadTopStories);
  176. assert.notCalled(instance.onInit);
  177. });
  178. });
  179. describe("#init", () => {
  180. it("should create a TopStoriesFeed", () => {
  181. assert.instanceOf(instance, TopStoriesFeed);
  182. });
  183. it("should bind parseOptions to SectionsManager.onceInitialized", () => {
  184. instance.onAction({type: at.PREFS_INITIAL_VALUES, data: {}});
  185. assert.calledOnce(sectionsManagerStub.onceInitialized);
  186. });
  187. it("should initialize endpoints based on options", async () => {
  188. await instance.onInit();
  189. assert.equal("https://somedomain.org/stories?key=test-api-key", instance.stories_endpoint);
  190. assert.equal("https://somedomain.org/referrer", instance.stories_referrer);
  191. assert.equal("https://somedomain.org/topics?key=test-api-key", instance.topics_endpoint);
  192. });
  193. it("should enable its section", () => {
  194. instance.onAction({type: at.PREFS_INITIAL_VALUES, data: {}});
  195. assert.calledOnce(sectionsManagerStub.enableSection);
  196. assert.calledWith(sectionsManagerStub.enableSection, SECTION_ID);
  197. });
  198. it("init should fire onInit", () => {
  199. instance.onInit = sinon.spy();
  200. instance.onAction({type: at.PREFS_INITIAL_VALUES, data: {}});
  201. assert.calledOnce(instance.onInit);
  202. });
  203. it("should fetch stories on init", async () => {
  204. instance.fetchStories = sinon.spy();
  205. await instance.onInit();
  206. assert.calledOnce(instance.fetchStories);
  207. });
  208. it("should fetch topics on init", async () => {
  209. instance.fetchTopics = sinon.spy();
  210. await instance.onInit();
  211. assert.calledOnce(instance.fetchTopics);
  212. });
  213. it("should not fetch if endpoint not configured", () => {
  214. let fetchStub = globals.sandbox.stub();
  215. globals.set("fetch", fetchStub);
  216. sectionsManagerStub.sections.set("topstories", {options: {}});
  217. instance.init();
  218. assert.notCalled(fetchStub);
  219. });
  220. it("should report error for invalid configuration", () => {
  221. globals.sandbox.spy(global.Cu, "reportError");
  222. sectionsManagerStub.sections.set("topstories", {
  223. options: {
  224. api_key_pref: "invalid",
  225. stories_endpoint: "https://invalid.com/?apiKey=$apiKey",
  226. },
  227. });
  228. instance.init();
  229. assert.calledWith(Cu.reportError, "Problem initializing top stories feed: An API key was specified but none configured: https://invalid.com/?apiKey=$apiKey");
  230. });
  231. it("should report error for missing api key", () => {
  232. globals.sandbox.spy(global.Cu, "reportError");
  233. sectionsManagerStub.sections.set("topstories", {
  234. options: {
  235. "stories_endpoint": "https://somedomain.org/stories?key=$apiKey",
  236. "topics_endpoint": "https://somedomain.org/topics?key=$apiKey",
  237. },
  238. });
  239. instance.init();
  240. assert.called(Cu.reportError);
  241. });
  242. it("should load data from cache on init", async () => {
  243. instance.loadCachedData = sinon.spy();
  244. await instance.onInit();
  245. assert.calledOnce(instance.loadCachedData);
  246. });
  247. });
  248. describe("#uninit", () => {
  249. it("should disable its section", () => {
  250. instance.onAction({type: at.UNINIT});
  251. assert.calledOnce(sectionsManagerStub.disableSection);
  252. assert.calledWith(sectionsManagerStub.disableSection, SECTION_ID);
  253. });
  254. it("should unload stories on uninit", async () => {
  255. sinon.stub(instance.cache, "set").returns(Promise.resolve());
  256. await instance.clearCache();
  257. assert.calledWith(instance.cache.set.firstCall, "stories", {});
  258. assert.calledWith(instance.cache.set.secondCall, "topics", {});
  259. assert.calledWith(instance.cache.set.thirdCall, "spocs", {});
  260. });
  261. });
  262. describe("#cache", () => {
  263. it("should clear all cache items when calling clearCache", () => {
  264. sinon.stub(instance.cache, "set").returns(Promise.resolve());
  265. instance.storiesLoaded = true;
  266. instance.uninit();
  267. assert.equal(instance.storiesLoaded, false);
  268. });
  269. it("should set spocs cache on fetch", async () => {
  270. const response = {
  271. "recommendations": [{"id": "1"}, {"id": "2"}],
  272. "settings": {"timeSegments": {}, "domainAffinityParameterSets": {}},
  273. "spocs": [{"id": "spoc1"}],
  274. };
  275. instance.show_spocs = true;
  276. instance.personalized = true;
  277. instance.stories_endpoint = "stories-endpoint";
  278. let fetchStub = globals.sandbox.stub();
  279. globals.set("fetch", fetchStub);
  280. globals.set("NewTabUtils", {blockedLinks: {isBlocked: () => {}}});
  281. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  282. sinon.spy(instance.cache, "set");
  283. await instance.fetchStories();
  284. assert.calledOnce(instance.cache.set);
  285. const {args} = instance.cache.set.firstCall;
  286. assert.equal(args[0], "stories");
  287. assert.equal(args[1].spocs[0].id, "spoc1");
  288. });
  289. it("should get spocs on cache load", async () => {
  290. instance.cache.get = () => ({
  291. stories: {
  292. recommendations: [{"id": "1"}, {"id": "2"}],
  293. spocs: [{"id": "spoc1"}],
  294. },
  295. });
  296. instance.storiesLastUpdated = 0;
  297. globals.set("NewTabUtils", {blockedLinks: {isBlocked: () => {}}});
  298. await instance.loadCachedData();
  299. assert.equal(instance.spocs[0].guid, "spoc1");
  300. });
  301. });
  302. describe("#fetch", () => {
  303. it("should fetch stories, send event and cache results", async () => {
  304. let fetchStub = globals.sandbox.stub();
  305. sectionsManagerStub.sections.set("topstories", {
  306. options: {
  307. stories_endpoint: "stories-endpoint",
  308. stories_referrer: "referrer",
  309. },
  310. });
  311. globals.set("fetch", fetchStub);
  312. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  313. const response = {
  314. "recommendations": [{
  315. "id": "1",
  316. "title": "title",
  317. "excerpt": "description",
  318. "image_src": "image-url",
  319. "url": "rec-url",
  320. "published_timestamp": "123",
  321. "context": "trending",
  322. "icon": "icon",
  323. }],
  324. };
  325. const stories = [{
  326. "guid": "1",
  327. "type": "now",
  328. "title": "title",
  329. "context": "trending",
  330. "icon": "icon",
  331. "description": "description",
  332. "image": "image-url",
  333. "referrer": "referrer",
  334. "url": "rec-url",
  335. "hostname": "rec-url",
  336. "min_score": 0,
  337. "score": 1,
  338. "spoc_meta": {},
  339. }];
  340. instance.cache.set = sinon.spy();
  341. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  342. await instance.onInit();
  343. assert.calledOnce(fetchStub);
  344. assert.calledOnce(shortURLStub);
  345. assert.calledWithExactly(fetchStub, instance.stories_endpoint, {credentials: "omit"});
  346. assert.calledOnce(sectionsManagerStub.updateSection);
  347. assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {rows: stories});
  348. assert.calledOnce(instance.cache.set);
  349. assert.calledWith(instance.cache.set, "stories", Object.assign({}, response, {_timestamp: 0}));
  350. });
  351. it("should use domain as hostname, if present", async () => {
  352. let fetchStub = globals.sandbox.stub();
  353. sectionsManagerStub.sections.set("topstories", {
  354. options: {
  355. stories_endpoint: "stories-endpoint",
  356. stories_referrer: "referrer",
  357. },
  358. });
  359. globals.set("fetch", fetchStub);
  360. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  361. const response = {
  362. "recommendations": [{
  363. "id": "1",
  364. "title": "title",
  365. "excerpt": "description",
  366. "image_src": "image-url",
  367. "url": "rec-url",
  368. "domain": "domain",
  369. "published_timestamp": "123",
  370. "context": "trending",
  371. "icon": "icon",
  372. }],
  373. };
  374. const stories = [{
  375. "guid": "1",
  376. "type": "now",
  377. "title": "title",
  378. "context": "trending",
  379. "icon": "icon",
  380. "description": "description",
  381. "image": "image-url",
  382. "referrer": "referrer",
  383. "url": "rec-url",
  384. "hostname": "domain",
  385. "min_score": 0,
  386. "score": 1,
  387. "spoc_meta": {},
  388. }];
  389. instance.cache.set = sinon.spy();
  390. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  391. await instance.onInit();
  392. assert.calledOnce(fetchStub);
  393. assert.notCalled(shortURLStub);
  394. assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {rows: stories});
  395. });
  396. it("should call SectionsManager.updateSection", () => {
  397. instance.dispatchUpdateEvent(123, {});
  398. assert.calledOnce(sectionsManagerStub.updateSection);
  399. });
  400. it("should report error for unexpected stories response", async () => {
  401. let fetchStub = globals.sandbox.stub();
  402. sectionsManagerStub.sections.set("topstories", {options: {stories_endpoint: "stories-endpoint"}});
  403. globals.set("fetch", fetchStub);
  404. globals.sandbox.spy(global.Cu, "reportError");
  405. fetchStub.resolves({ok: false, status: 400});
  406. await instance.onInit();
  407. assert.calledOnce(fetchStub);
  408. assert.calledWithExactly(fetchStub, instance.stories_endpoint, {credentials: "omit"});
  409. assert.equal(instance.storiesLastUpdated, 0);
  410. assert.called(Cu.reportError);
  411. });
  412. it("should exclude blocked (dismissed) URLs", async () => {
  413. let fetchStub = globals.sandbox.stub();
  414. sectionsManagerStub.sections.set("topstories", {options: {stories_endpoint: "stories-endpoint"}});
  415. globals.set("fetch", fetchStub);
  416. globals.set("NewTabUtils", {blockedLinks: {isBlocked: site => site.url === "blocked"}});
  417. const response = {"recommendations": [{"url": "blocked"}, {"url": "not_blocked"}]};
  418. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  419. await instance.onInit();
  420. // Issue!
  421. // Should actually be fixed when cache is fixed.
  422. assert.calledOnce(sectionsManagerStub.updateSection);
  423. assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows.length, 1);
  424. assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows[0].url, "not_blocked");
  425. });
  426. it("should mark stories as new", async () => {
  427. let fetchStub = globals.sandbox.stub();
  428. sectionsManagerStub.sections.set("topstories", {options: {stories_endpoint: "stories-endpoint"}});
  429. globals.set("fetch", fetchStub);
  430. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  431. clock.restore();
  432. const response = {
  433. "recommendations": [
  434. {"published_timestamp": Date.now() / 1000},
  435. {"published_timestamp": "0"},
  436. {"published_timestamp": (Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000},
  437. ],
  438. };
  439. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  440. await instance.onInit();
  441. assert.calledOnce(sectionsManagerStub.updateSection);
  442. assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows.length, 3);
  443. assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows[0].type, "now");
  444. assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows[1].type, "trending");
  445. assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows[2].type, "trending");
  446. });
  447. it("should fetch topics, send event and cache results", async () => {
  448. let fetchStub = globals.sandbox.stub();
  449. sectionsManagerStub.sections.set("topstories", {options: {topics_endpoint: "topics-endpoint"}});
  450. globals.set("fetch", fetchStub);
  451. const response = {"topics": [{"name": "topic1", "url": "url-topic1"}, {"name": "topic2", "url": "url-topic2"}]};
  452. const topics = [{
  453. "name": "topic1",
  454. "url": "url-topic1",
  455. }, {
  456. "name": "topic2",
  457. "url": "url-topic2",
  458. }];
  459. instance.cache.set = sinon.spy();
  460. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  461. await instance.onInit();
  462. assert.calledOnce(fetchStub);
  463. assert.calledWithExactly(fetchStub, instance.topics_endpoint, {credentials: "omit"});
  464. assert.calledOnce(sectionsManagerStub.updateSection);
  465. assert.calledWithMatch(sectionsManagerStub.updateSection, SECTION_ID, {topics});
  466. assert.calledOnce(instance.cache.set);
  467. assert.calledWith(instance.cache.set, "topics", Object.assign({}, response, {_timestamp: 0}));
  468. });
  469. it("should report error for unexpected topics response", async () => {
  470. let fetchStub = globals.sandbox.stub();
  471. globals.set("fetch", fetchStub);
  472. globals.sandbox.spy(global.Cu, "reportError");
  473. instance.topics_endpoint = "topics-endpoint";
  474. fetchStub.resolves({ok: false, status: 400});
  475. await instance.fetchTopics();
  476. assert.calledOnce(fetchStub);
  477. assert.calledWithExactly(fetchStub, instance.topics_endpoint, {credentials: "omit"});
  478. assert.notCalled(instance.store.dispatch);
  479. assert.called(Cu.reportError);
  480. });
  481. });
  482. describe("#personalization", () => {
  483. it("should sort stories if personalization is preffed on", async () => {
  484. const response = {
  485. "recommendations": [{"id": "1"}, {"id": "2"}],
  486. "settings": {"timeSegments": {}, "domainAffinityParameterSets": {}},
  487. };
  488. instance.personalized = true;
  489. instance.compareScore = sinon.spy();
  490. instance.stories_endpoint = "stories-endpoint";
  491. let fetchStub = globals.sandbox.stub();
  492. globals.set("fetch", fetchStub);
  493. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  494. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  495. await instance.fetchStories();
  496. assert.calledOnce(instance.compareScore);
  497. });
  498. it("should not sort stories if personalization is preffed off", async () => {
  499. const response = `{
  500. "recommendations": [{"id" : "1"}, {"id" : "2"}],
  501. "settings": {"timeSegments": {}, "domainAffinityParameterSets": {}}
  502. }`;
  503. instance.personalized = false;
  504. instance.compareScore = sinon.spy();
  505. instance.stories_endpoint = "stories-endpoint";
  506. let fetchStub = globals.sandbox.stub();
  507. globals.set("fetch", fetchStub);
  508. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  509. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  510. await instance.fetchStories();
  511. assert.notCalled(instance.compareScore);
  512. });
  513. it("should sort items based on relevance score", () => {
  514. let items = [{"score": 0.1}, {"score": 0.2}];
  515. items = items.sort(instance.compareScore);
  516. assert.deepEqual(items, [{"score": 0.2}, {"score": 0.1}]);
  517. });
  518. it("should rotate items if personalization is preffed on", () => {
  519. let items = [{"guid": "g1"}, {"guid": "g2"}, {"guid": "g3"}, {"guid": "g4"}, {"guid": "g5"}, {"guid": "g6"}];
  520. instance.personalized = true;
  521. // No impressions should leave items unchanged
  522. let rotated = instance.rotate(items);
  523. assert.deepEqual(items, rotated);
  524. // Recent impression should leave items unchanged
  525. instance._prefs.get = pref => (pref === REC_IMPRESSION_TRACKING_PREF) && JSON.stringify({"g1": 1, "g2": 1, "g3": 1});
  526. rotated = instance.rotate(items);
  527. assert.deepEqual(items, rotated);
  528. // Impression older than expiration time should rotate items
  529. clock.tick(DEFAULT_RECS_EXPIRE_TIME + 1);
  530. rotated = instance.rotate(items);
  531. assert.deepEqual([{"guid": "g4"}, {"guid": "g5"}, {"guid": "g6"}, {"guid": "g1"}, {"guid": "g2"}, {"guid": "g3"}], rotated);
  532. instance._prefs.get = pref => (pref === REC_IMPRESSION_TRACKING_PREF) &&
  533. JSON.stringify({"g1": 1, "g2": 1, "g3": 1, "g4": DEFAULT_RECS_EXPIRE_TIME + 1});
  534. clock.tick(DEFAULT_RECS_EXPIRE_TIME);
  535. rotated = instance.rotate(items);
  536. assert.deepEqual([{"guid": "g5"}, {"guid": "g6"}, {"guid": "g1"}, {"guid": "g2"}, {"guid": "g3"}, {"guid": "g4"}], rotated);
  537. });
  538. it("should not rotate items if personalization is preffed off", () => {
  539. let items = [{"guid": "g1"}, {"guid": "g2"}, {"guid": "g3"}, {"guid": "g4"}];
  540. instance.personalized = false;
  541. instance._prefs.get = pref => (pref === REC_IMPRESSION_TRACKING_PREF) && JSON.stringify({"g1": 1, "g2": 1, "g3": 1});
  542. clock.tick(DEFAULT_RECS_EXPIRE_TIME + 1);
  543. let rotated = instance.rotate(items);
  544. assert.deepEqual(items, rotated);
  545. });
  546. it("should not dispatch ITEM_RELEVANCE_SCORE_DURATION metrics for not personalized", () => {
  547. instance.personalized = false;
  548. instance.dispatchRelevanceScore(50);
  549. assert.notCalled(instance.store.dispatch);
  550. });
  551. it("should not dispatch v2 ITEM_RELEVANCE_SCORE_DURATION until initialized", () => {
  552. instance.personalized = true;
  553. instance.affinityProviderV2 = {use_v2: true};
  554. instance.affinityProvider = {};
  555. instance.dispatchRelevanceScore(50);
  556. assert.notCalled(instance.store.dispatch);
  557. instance.affinityProvider = {initialized: true};
  558. instance.dispatchRelevanceScore(50);
  559. assert.calledOnce(instance.store.dispatch);
  560. const [action] = instance.store.dispatch.firstCall.args;
  561. assert.equal(action.type, "TELEMETRY_PERFORMANCE_EVENT");
  562. assert.equal(action.data.event, "PERSONALIZATION_V2_ITEM_RELEVANCE_SCORE_DURATION");
  563. });
  564. it("should dispatch v1 ITEM_RELEVANCE_SCORE_DURATION", () => {
  565. instance.personalized = true;
  566. instance.dispatchRelevanceScore(50);
  567. assert.calledOnce(instance.store.dispatch);
  568. const [action] = instance.store.dispatch.firstCall.args;
  569. assert.equal(action.type, "TELEMETRY_PERFORMANCE_EVENT");
  570. assert.equal(action.data.event, "PERSONALIZATION_V1_ITEM_RELEVANCE_SCORE_DURATION");
  571. });
  572. it("should record top story impressions", async () => {
  573. instance._prefs = {get: pref => undefined, set: sinon.spy()};
  574. instance.personalized = true;
  575. clock.tick(1);
  576. let expectedPrefValue = JSON.stringify({1: 1, 2: 1, 3: 1});
  577. instance.onAction({type: at.TELEMETRY_IMPRESSION_STATS, data: {source: "TOP_STORIES", tiles: [{id: 1}, {id: 2}, {id: 3}]}});
  578. assert.calledWith(instance._prefs.set.firstCall, REC_IMPRESSION_TRACKING_PREF, expectedPrefValue);
  579. // Only need to record first impression, so impression pref shouldn't change
  580. instance._prefs.get = pref => expectedPrefValue;
  581. clock.tick(1);
  582. instance.onAction({type: at.TELEMETRY_IMPRESSION_STATS, data: {source: "TOP_STORIES", tiles: [{id: 1}, {id: 2}, {id: 3}]}});
  583. assert.calledOnce(instance._prefs.set);
  584. // New first impressions should be added
  585. clock.tick(1);
  586. let expectedPrefValueTwo = JSON.stringify({1: 1, 2: 1, 3: 1, 4: 3, 5: 3, 6: 3});
  587. instance.onAction({type: at.TELEMETRY_IMPRESSION_STATS, data: {source: "TOP_STORIES", tiles: [{id: 4}, {id: 5}, {id: 6}]}});
  588. assert.calledWith(instance._prefs.set.secondCall, REC_IMPRESSION_TRACKING_PREF, expectedPrefValueTwo);
  589. });
  590. it("should not record top story impressions for non-view impressions", async () => {
  591. instance._prefs = {get: pref => undefined, set: sinon.spy()};
  592. instance.personalized = true;
  593. instance.onAction({type: at.TELEMETRY_IMPRESSION_STATS, data: {source: "TOP_STORIES", click: 0, tiles: [{id: 1}]}});
  594. assert.notCalled(instance._prefs.set);
  595. instance.onAction({type: at.TELEMETRY_IMPRESSION_STATS, data: {source: "TOP_STORIES", block: 0, tiles: [{id: 1}]}});
  596. assert.notCalled(instance._prefs.set);
  597. instance.onAction({type: at.TELEMETRY_IMPRESSION_STATS, data: {source: "TOP_STORIES", pocket: 0, tiles: [{id: 1}]}});
  598. assert.notCalled(instance._prefs.set);
  599. });
  600. it("should clean up top story impressions", async () => {
  601. instance._prefs = {get: pref => JSON.stringify({1: 1, 2: 1, 3: 1}), set: sinon.spy()};
  602. let fetchStub = globals.sandbox.stub();
  603. globals.set("fetch", fetchStub);
  604. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  605. instance.stories_endpoint = "stories-endpoint";
  606. const response = {"recommendations": [{"id": 3}, {"id": 4}, {"id": 5}]};
  607. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  608. await instance.fetchStories();
  609. // Should remove impressions for rec 1 and 2 as no longer in the feed
  610. assert.calledWith(instance._prefs.set.firstCall, REC_IMPRESSION_TRACKING_PREF, JSON.stringify({3: 1}));
  611. });
  612. it("should re init on affinityProviderV2 pref change", async () => {
  613. sinon.stub(instance, "uninit");
  614. sinon.stub(instance, "init");
  615. sinon.stub(instance, "clearCache").returns(Promise.resolve());
  616. await instance.onAction({type: at.PREF_CHANGED, data: {name: "feeds.section.topstories.options", value: JSON.stringify({version: 2})}});
  617. assert.calledOnce(instance.uninit);
  618. assert.calledOnce(instance.init);
  619. assert.calledOnce(instance.clearCache);
  620. });
  621. it("should use UserDomainAffinityProvider from affinityProividerSwitcher not using v2", async () => {
  622. instance.affinityProviderV2 = {use_v2: false};
  623. sinon.stub(instance, "UserDomainAffinityProvider");
  624. sinon.stub(instance, "PersonalityProvider");
  625. await instance.affinityProividerSwitcher();
  626. assert.notCalled(instance.PersonalityProvider);
  627. assert.calledOnce(instance.UserDomainAffinityProvider);
  628. });
  629. it("should not change provider with badly formed JSON", async () => {
  630. sinon.stub(instance, "uninit");
  631. sinon.stub(instance, "init");
  632. sinon.stub(instance, "clearCache").returns(Promise.resolve());
  633. await instance.onAction({type: at.PREF_CHANGED, data: {name: "feeds.section.topstories.options", value: "{version: 2}"}});
  634. assert.notCalled(instance.uninit);
  635. assert.notCalled(instance.init);
  636. assert.notCalled(instance.clearCache);
  637. });
  638. it("should use PersonalityProvider from affinityProividerSwitcher using v2", async () => {
  639. instance.affinityProviderV2 = {use_v2: true};
  640. sinon.stub(instance, "UserDomainAffinityProvider");
  641. sinon.stub(instance, "PersonalityProvider");
  642. instance.PersonalityProvider = () => ({init: sinon.stub()});
  643. const provider = instance.affinityProividerSwitcher();
  644. assert.calledOnce(provider.init);
  645. assert.notCalled(instance.UserDomainAffinityProvider);
  646. });
  647. it("should use init and callback from affinityProividerSwitcher using v2", async () => {
  648. const stories = {recommendations: {}};
  649. sinon.stub(instance, "doContentUpdate");
  650. sinon.stub(instance, "rotate").returns(stories);
  651. sinon.stub(instance, "transform");
  652. instance.cache.get = () => ({stories});
  653. instance.cache.set = sinon.spy();
  654. instance.affinityProvider = {getAffinities: () => ({})};
  655. await instance.onPersonalityProviderInit();
  656. assert.calledOnce(instance.doContentUpdate);
  657. assert.calledWith(instance.doContentUpdate, {stories: {recommendations: {}}}, false);
  658. assert.calledOnce(instance.rotate);
  659. assert.calledOnce(instance.transform);
  660. const {args} = instance.cache.set.firstCall;
  661. assert.equal(args[0], "domainAffinities");
  662. assert.equal(args[1]._timestamp, 0);
  663. });
  664. it("should call dispatchUpdateEvent from affinityProividerSwitcher using v2", async () => {
  665. const stories = {recommendations: {}};
  666. sinon.stub(instance, "rotate").returns(stories);
  667. sinon.stub(instance, "transform");
  668. sinon.spy(instance, "dispatchUpdateEvent");
  669. instance.cache.get = () => ({stories});
  670. instance.cache.set = sinon.spy();
  671. instance.affinityProvider = {getAffinities: () => ({})};
  672. await instance.onPersonalityProviderInit();
  673. assert.calledOnce(instance.dispatchUpdateEvent);
  674. });
  675. it("should return an object for UserDomainAffinityProvider", () => {
  676. assert.equal(typeof instance.UserDomainAffinityProvider(), "object");
  677. });
  678. it("should return an object for PersonalityProvider", () => {
  679. assert.equal(typeof instance.PersonalityProvider(), "object");
  680. });
  681. it("should call affinityProividerSwitcher on loadCachedData", async () => {
  682. instance.affinityProviderV2 = true;
  683. instance.personalized = true;
  684. sinon.stub(instance, "affinityProividerSwitcher").returns(Promise.resolve());
  685. const domainAffinities = {
  686. "parameterSets": {
  687. "default": {
  688. "recencyFactor": 0.4,
  689. "frequencyFactor": 0.5,
  690. "combinedDomainFactor": 0.5,
  691. "perfectFrequencyVisits": 10,
  692. "perfectCombinedDomainScore": 2,
  693. "multiDomainBoost": 0.1,
  694. "itemScoreFactor": 0,
  695. },
  696. },
  697. "scores": {"a.com": 1, "b.com": 0.9},
  698. "maxHistoryQueryResults": 1000,
  699. "timeSegments": {},
  700. "version": "v1",
  701. };
  702. instance.cache.get = () => ({domainAffinities});
  703. await instance.loadCachedData();
  704. assert.calledOnce(instance.affinityProividerSwitcher);
  705. });
  706. it("should change domainAffinitiesLastUpdated on loadCachedData", async () => {
  707. instance.affinityProviderV2 = true;
  708. instance.personalized = true;
  709. const domainAffinities = {
  710. "parameterSets": {
  711. "default": {
  712. "recencyFactor": 0.4,
  713. "frequencyFactor": 0.5,
  714. "combinedDomainFactor": 0.5,
  715. "perfectFrequencyVisits": 10,
  716. "perfectCombinedDomainScore": 2,
  717. "multiDomainBoost": 0.1,
  718. "itemScoreFactor": 0,
  719. },
  720. },
  721. "scores": {"a.com": 1, "b.com": 0.9},
  722. "maxHistoryQueryResults": 1000,
  723. "timeSegments": {},
  724. "version": "v1",
  725. };
  726. instance.cache.get = () => ({domainAffinities});
  727. await instance.loadCachedData();
  728. assert.notEqual(instance.domainAffinitiesLastUpdated, 0);
  729. });
  730. it("should return false and do nothing if v2 already set", () => {
  731. instance.affinityProviderV2 = {use_v2: true, model_keys: ["item1orig"]};
  732. const result = instance.processAffinityProividerVersion({version: 2, model_keys: ["item1"]});
  733. assert.isTrue(instance.affinityProviderV2.use_v2);
  734. assert.isFalse(result);
  735. assert.equal(instance.affinityProviderV2.model_keys[0], "item1orig");
  736. });
  737. it("should return false and do nothing if v1 already set", () => {
  738. instance.affinityProviderV2 = null;
  739. const result = instance.processAffinityProividerVersion({version: 1});
  740. assert.isFalse(result);
  741. assert.isNull(instance.affinityProviderV2);
  742. });
  743. it("should return true and set v2", () => {
  744. const result = instance.processAffinityProividerVersion({version: 2, model_keys: ["item1"]});
  745. assert.isTrue(instance.affinityProviderV2.use_v2);
  746. assert.isTrue(result);
  747. assert.equal(instance.affinityProviderV2.model_keys[0], "item1");
  748. });
  749. it("should return true and set v1", () => {
  750. instance.affinityProviderV2 = {};
  751. const result = instance.processAffinityProividerVersion({version: 1});
  752. assert.isTrue(result);
  753. assert.isNull(instance.affinityProviderV2);
  754. });
  755. });
  756. describe("#spocs", async () => {
  757. it("should not display expired or untimestamped spocs", async () => {
  758. clock.tick(441792000000); // 01/01/1984
  759. instance.spocsPerNewTabs = 1;
  760. instance.show_spocs = true;
  761. instance.isBelowFrequencyCap = () => true;
  762. // NOTE: `expiration_timestamp` is seconds since UNIX epoch
  763. instance.spocs = [
  764. // No timestamp stays visible
  765. {
  766. id: "spoc1",
  767. },
  768. // Expired spoc gets filtered out
  769. {
  770. id: "spoc2",
  771. expiration_timestamp: 1,
  772. },
  773. // Far future expiration spoc stays visible
  774. {
  775. id: "spoc3",
  776. expiration_timestamp: 32503708800, // 01/01/3000
  777. },
  778. ];
  779. sinon.spy(instance, "filterSpocs");
  780. instance.filterSpocs();
  781. assert.equal(instance.filterSpocs.firstCall.returnValue.length, 2);
  782. assert.equal(instance.filterSpocs.firstCall.returnValue[0].id, "spoc1");
  783. assert.equal(instance.filterSpocs.firstCall.returnValue[1].id, "spoc3");
  784. });
  785. it("should insert spoc with provided probability", async () => {
  786. let fetchStub = globals.sandbox.stub();
  787. globals.set("fetch", fetchStub);
  788. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  789. instance.dispatchRelevanceScore = () => {};
  790. const response = {
  791. "settings": {"spocsPerNewTabs": 0.5},
  792. "recommendations": [{"guid": "rec1"}, {"guid": "rec2"}, {"guid": "rec3"}],
  793. // Include spocs with a expiration in the very distant future
  794. "spocs": [
  795. {"id": "spoc1", "expiration_timestamp": 9999999999999},
  796. {"id": "spoc2", "expiration_timestamp": 9999999999999},
  797. ],
  798. };
  799. instance.personalized = true;
  800. instance.show_spocs = true;
  801. instance.stories_endpoint = "stories-endpoint";
  802. instance.storiesLoaded = true;
  803. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  804. await instance.fetchStories();
  805. instance.store.getState = () => ({Sections: [{id: "topstories", rows: response.recommendations}], Prefs: {values: {showSponsored: true}}});
  806. globals.set("Math", {
  807. random: () => 0.4,
  808. min: Math.min,
  809. });
  810. instance.dispatchSpocDone = () => {};
  811. instance.getPocketState = () => {};
  812. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  813. assert.calledOnce(instance.store.dispatch);
  814. let [action] = instance.store.dispatch.firstCall.args;
  815. assert.equal(at.SECTION_UPDATE, action.type);
  816. assert.equal(true, action.meta.skipMain);
  817. assert.equal(action.data.rows[0].guid, "rec1");
  818. assert.equal(action.data.rows[1].guid, "rec2");
  819. assert.equal(action.data.rows[2].guid, "spoc1");
  820. // Make sure spoc is marked as pinned so it doesn't get removed when preloaded tabs refresh
  821. assert.equal(action.data.rows[2].pinned, true);
  822. // Second new tab shouldn't trigger a section update event (spocsPerNewTab === 0.5)
  823. globals.set("Math", {
  824. random: () => 0.6,
  825. min: Math.min,
  826. });
  827. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  828. assert.calledOnce(instance.store.dispatch);
  829. globals.set("Math", {
  830. random: () => 0.3,
  831. min: Math.min,
  832. });
  833. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  834. assert.calledTwice(instance.store.dispatch);
  835. [action] = instance.store.dispatch.secondCall.args;
  836. assert.equal(at.SECTION_UPDATE, action.type);
  837. assert.equal(true, action.meta.skipMain);
  838. assert.equal(action.data.rows[0].guid, "rec1");
  839. assert.equal(action.data.rows[1].guid, "rec2");
  840. assert.equal(action.data.rows[2].guid, "spoc1");
  841. // Make sure spoc is marked as pinned so it doesn't get removed when preloaded tabs refresh
  842. assert.equal(action.data.rows[2].pinned, true);
  843. });
  844. it("should delay inserting spoc if stories haven't been fetched", async () => {
  845. let fetchStub = globals.sandbox.stub();
  846. instance.dispatchRelevanceScore = () => {};
  847. instance.dispatchSpocDone = () => {};
  848. sectionsManagerStub.sections.set("topstories", {
  849. options: {
  850. show_spocs: true,
  851. personalized: true,
  852. stories_endpoint: "stories-endpoint",
  853. },
  854. });
  855. globals.set("fetch", fetchStub);
  856. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  857. globals.set("Math", {
  858. random: () => 0.4,
  859. min: Math.min,
  860. floor: Math.floor,
  861. });
  862. instance.getPocketState = () => {};
  863. instance.dispatchPocketCta = () => {};
  864. const response = {
  865. "settings": {"spocsPerNewTabs": 0.5},
  866. "recommendations": [{"id": "rec1"}, {"id": "rec2"}, {"id": "rec3"}],
  867. // Include one spoc with a expiration in the very distant future
  868. "spocs": [{"id": "spoc1", "expiration_timestamp": 9999999999999}, {"id": "spoc2"}],
  869. };
  870. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  871. assert.notCalled(instance.store.dispatch);
  872. assert.equal(instance.contentUpdateQueue.length, 1);
  873. instance.spocsPerNewTabs = 0.5;
  874. instance.store.getState = () => ({Sections: [{id: "topstories", rows: response.recommendations}], Prefs: {values: {showSponsored: true}}});
  875. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  876. await instance.onInit();
  877. assert.equal(instance.contentUpdateQueue.length, 0);
  878. assert.calledOnce(instance.store.dispatch);
  879. let [action] = instance.store.dispatch.firstCall.args;
  880. assert.equal(action.type, at.SECTION_UPDATE);
  881. });
  882. it("should not insert spoc if preffed off", async () => {
  883. let fetchStub = globals.sandbox.stub();
  884. instance.dispatchSpocDone = () => {};
  885. sectionsManagerStub.sections.set("topstories", {
  886. options: {
  887. show_spocs: false,
  888. personalized: true,
  889. stories_endpoint: "stories-endpoint",
  890. },
  891. });
  892. globals.set("fetch", fetchStub);
  893. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  894. instance.getPocketState = () => {};
  895. instance.dispatchPocketCta = () => {};
  896. const response = {
  897. "settings": {"spocsPerNewTabs": 0.5},
  898. "spocs": [{"id": "spoc1"}, {"id": "spoc2"}],
  899. };
  900. sinon.spy(instance, "maybeAddSpoc");
  901. sinon.spy(instance, "shouldShowSpocs");
  902. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  903. await instance.onInit();
  904. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  905. assert.calledOnce(instance.maybeAddSpoc);
  906. assert.calledOnce(instance.shouldShowSpocs);
  907. assert.notCalled(instance.store.dispatch);
  908. });
  909. it("should call dispatchSpocDone when calling maybeAddSpoc", async () => {
  910. instance.dispatchSpocDone = sinon.spy();
  911. instance.storiesLoaded = true;
  912. await instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  913. assert.calledOnce(instance.dispatchSpocDone);
  914. assert.calledWith(instance.dispatchSpocDone, {});
  915. });
  916. it("should fire POCKET_WAITING_FOR_SPOC action with false", () => {
  917. instance.dispatchSpocDone({});
  918. assert.calledOnce(instance.store.dispatch);
  919. const [action] = instance.store.dispatch.firstCall.args;
  920. assert.equal(action.type, "POCKET_WAITING_FOR_SPOC");
  921. assert.equal(action.data, false);
  922. });
  923. it("should not insert spoc if user opted out", async () => {
  924. let fetchStub = globals.sandbox.stub();
  925. instance.dispatchRelevanceScore = () => {};
  926. instance.dispatchSpocDone = () => {};
  927. sectionsManagerStub.sections.set("topstories", {
  928. options: {
  929. show_spocs: true,
  930. personalized: true,
  931. stories_endpoint: "stories-endpoint",
  932. },
  933. });
  934. instance.getPocketState = () => {};
  935. instance.dispatchPocketCta = () => {};
  936. globals.set("fetch", fetchStub);
  937. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  938. const response = {
  939. "settings": {"spocsPerNewTabs": 0.5},
  940. "spocs": [{"id": "spoc1"}, {"id": "spoc2"}],
  941. };
  942. instance.store.getState = () => ({Sections: [{id: "topstories", rows: response.recommendations}], Prefs: {values: {showSponsored: false}}});
  943. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  944. await instance.onInit();
  945. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  946. assert.notCalled(instance.store.dispatch);
  947. });
  948. it("should not fail if there is no spoc", async () => {
  949. let fetchStub = globals.sandbox.stub();
  950. instance.dispatchSpocDone = () => {};
  951. sectionsManagerStub.sections.set("topstories", {
  952. options: {
  953. show_spocs: true,
  954. personalized: true,
  955. stories_endpoint: "stories-endpoint",
  956. },
  957. });
  958. instance.getPocketState = () => {};
  959. instance.dispatchPocketCta = () => {};
  960. globals.set("fetch", fetchStub);
  961. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  962. globals.set("Math", {
  963. random: () => 0.4,
  964. min: Math.min,
  965. });
  966. const response = {
  967. "settings": {"spocsPerNewTabs": 0.5},
  968. "recommendations": [{"id": "rec1"}, {"id": "rec2"}, {"id": "rec3"}],
  969. };
  970. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  971. await instance.onInit();
  972. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  973. assert.notCalled(instance.store.dispatch);
  974. });
  975. it("should record spoc/campaign impressions for frequency capping", async () => {
  976. let fetchStub = globals.sandbox.stub();
  977. globals.set("fetch", fetchStub);
  978. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  979. globals.set("Math", {
  980. random: () => 0.4,
  981. min: Math.min,
  982. floor: Math.floor,
  983. });
  984. const response = {
  985. "settings": {"spocsPerNewTabs": 0.5},
  986. "spocs": [{"id": 1, "campaign_id": 5}, {"id": 4, "campaign_id": 6}],
  987. };
  988. instance._prefs = {get: pref => undefined, set: sinon.spy()};
  989. instance.show_spocs = true;
  990. instance.stories_endpoint = "stories-endpoint";
  991. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  992. await instance.fetchStories();
  993. let expectedPrefValue = JSON.stringify({5: [0]});
  994. instance.onAction({type: at.TELEMETRY_IMPRESSION_STATS, data: {source: "TOP_STORIES", tiles: [{id: 3}, {id: 2}, {id: 1}]}});
  995. assert.calledWith(instance._prefs.set.firstCall, SPOC_IMPRESSION_TRACKING_PREF, expectedPrefValue);
  996. clock.tick(1);
  997. instance._prefs.get = pref => expectedPrefValue;
  998. let expectedPrefValueCallTwo = JSON.stringify({5: [0, 1]});
  999. instance.onAction({type: at.TELEMETRY_IMPRESSION_STATS, data: {source: "TOP_STORIES", tiles: [{id: 3}, {id: 2}, {id: 1}]}});
  1000. assert.calledWith(instance._prefs.set.secondCall, SPOC_IMPRESSION_TRACKING_PREF, expectedPrefValueCallTwo);
  1001. clock.tick(1);
  1002. instance._prefs.get = pref => expectedPrefValueCallTwo;
  1003. instance.onAction({type: at.TELEMETRY_IMPRESSION_STATS, data: {source: "TOP_STORIES", tiles: [{id: 3}, {id: 2}, {id: 4}]}});
  1004. assert.calledWith(instance._prefs.set.thirdCall, SPOC_IMPRESSION_TRACKING_PREF, JSON.stringify({5: [0, 1], 6: [2]}));
  1005. });
  1006. it("should not record spoc/campaign impressions for non-view impressions", async () => {
  1007. let fetchStub = globals.sandbox.stub();
  1008. sectionsManagerStub.sections.set("topstories", {
  1009. options: {
  1010. show_spocs: true,
  1011. stories_endpoint: "stories-endpoint",
  1012. },
  1013. });
  1014. globals.set("fetch", fetchStub);
  1015. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  1016. const response = {
  1017. "settings": {"spocsPerNewTabs": 0.5},
  1018. "spocs": [{"id": 1, "campaign_id": 5}, {"id": 4, "campaign_id": 6}],
  1019. };
  1020. instance._prefs = {get: pref => undefined, set: sinon.spy()};
  1021. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  1022. await instance.onInit();
  1023. instance.onAction({type: at.TELEMETRY_IMPRESSION_STATS, data: {source: "TOP_STORIES", click: 0, tiles: [{id: 1}]}});
  1024. assert.notCalled(instance._prefs.set);
  1025. instance.onAction({type: at.TELEMETRY_IMPRESSION_STATS, data: {source: "TOP_STORIES", block: 0, tiles: [{id: 1}]}});
  1026. assert.notCalled(instance._prefs.set);
  1027. instance.onAction({type: at.TELEMETRY_IMPRESSION_STATS, data: {source: "TOP_STORIES", pocket: 0, tiles: [{id: 1}]}});
  1028. assert.notCalled(instance._prefs.set);
  1029. });
  1030. it("should clean up spoc/campaign impressions", async () => {
  1031. let fetchStub = globals.sandbox.stub();
  1032. globals.set("fetch", fetchStub);
  1033. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  1034. instance._prefs = {get: pref => undefined, set: sinon.spy()};
  1035. instance.show_spocs = true;
  1036. instance.stories_endpoint = "stories-endpoint";
  1037. const response = {
  1038. "settings": {"spocsPerNewTabs": 0.5},
  1039. "spocs": [{"id": 1, "campaign_id": 5}, {"id": 4, "campaign_id": 6}],
  1040. };
  1041. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  1042. await instance.fetchStories();
  1043. // simulate impressions for campaign 5 and 6
  1044. instance.onAction({type: at.TELEMETRY_IMPRESSION_STATS, data: {source: "TOP_STORIES", tiles: [{id: 3}, {id: 2}, {id: 1}]}});
  1045. instance._prefs.get = pref => (pref === SPOC_IMPRESSION_TRACKING_PREF) && JSON.stringify({5: [0]});
  1046. instance.onAction({type: at.TELEMETRY_IMPRESSION_STATS, data: {source: "TOP_STORIES", tiles: [{id: 3}, {id: 2}, {id: 4}]}});
  1047. let expectedPrefValue = JSON.stringify({5: [0], 6: [0]});
  1048. assert.calledWith(instance._prefs.set.secondCall, SPOC_IMPRESSION_TRACKING_PREF, expectedPrefValue);
  1049. instance._prefs.get = pref => (pref === SPOC_IMPRESSION_TRACKING_PREF) && expectedPrefValue;
  1050. // remove campaign 5 from response
  1051. const updatedResponse = {
  1052. "settings": {"spocsPerNewTabs": 1},
  1053. "spocs": [{"id": 4, "campaign_id": 6}],
  1054. };
  1055. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(updatedResponse)});
  1056. await instance.fetchStories();
  1057. // should remove campaign 5 from pref as no longer active
  1058. assert.calledWith(instance._prefs.set.thirdCall, SPOC_IMPRESSION_TRACKING_PREF, JSON.stringify({6: [0]}));
  1059. });
  1060. it("should maintain frequency caps when inserting spocs", async () => {
  1061. let fetchStub = globals.sandbox.stub();
  1062. instance.dispatchRelevanceScore = () => {};
  1063. instance.dispatchSpocDone = () => {};
  1064. sectionsManagerStub.sections.set("topstories", {
  1065. options: {
  1066. show_spocs: true,
  1067. personalized: true,
  1068. stories_endpoint: "stories-endpoint",
  1069. },
  1070. });
  1071. instance.getPocketState = () => {};
  1072. instance.dispatchPocketCta = () => {};
  1073. globals.set("fetch", fetchStub);
  1074. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  1075. const response = {
  1076. "settings": {"spocsPerNewTabs": 1},
  1077. "recommendations": [{"guid": "rec1"}, {"guid": "rec2"}, {"guid": "rec3"}],
  1078. "spocs": [
  1079. // Set spoc `expiration_timestamp`s in the very distant future to ensure they show up
  1080. {"id": "spoc1", "campaign_id": 1, "caps": {"lifetime": 3, "campaign": {"count": 2, "period": 3600}}, "expiration_timestamp": 999999999999},
  1081. {"id": "spoc2", "campaign_id": 2, "caps": {"lifetime": 1}, "expiration_timestamp": 999999999999},
  1082. ],
  1083. };
  1084. instance.store.getState = () => ({Sections: [{id: "topstories", rows: response.recommendations}], Prefs: {values: {showSponsored: true}}});
  1085. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  1086. await instance.onInit();
  1087. instance.spocsPerNewTabs = 1;
  1088. clock.tick();
  1089. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  1090. let [action] = instance.store.dispatch.firstCall.args;
  1091. assert.equal(action.data.rows[0].guid, "rec1");
  1092. assert.equal(action.data.rows[1].guid, "rec2");
  1093. assert.equal(action.data.rows[2].guid, "spoc1");
  1094. instance._prefs.get = pref => JSON.stringify({1: [1]});
  1095. clock.tick();
  1096. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  1097. [action] = instance.store.dispatch.secondCall.args;
  1098. assert.equal(action.data.rows[0].guid, "rec1");
  1099. assert.equal(action.data.rows[1].guid, "rec2");
  1100. assert.equal(action.data.rows[2].guid, "spoc1");
  1101. instance._prefs.get = pref => JSON.stringify({1: [1, 2]});
  1102. // campaign 1 period frequency cap now reached (spoc 2 should be shown)
  1103. clock.tick();
  1104. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  1105. [action] = instance.store.dispatch.thirdCall.args;
  1106. assert.equal(action.data.rows[0].guid, "rec1");
  1107. assert.equal(action.data.rows[1].guid, "rec2");
  1108. assert.equal(action.data.rows[2].guid, "spoc2");
  1109. instance._prefs.get = pref => JSON.stringify({1: [1, 2], 2: [3]});
  1110. // new campaign 1 period starting (spoc 1 sohuld be shown again)
  1111. clock.tick(2 * 60 * 60 * 1000);
  1112. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  1113. [action] = instance.store.dispatch.lastCall.args;
  1114. assert.equal(action.data.rows[0].guid, "rec1");
  1115. assert.equal(action.data.rows[1].guid, "rec2");
  1116. assert.equal(action.data.rows[2].guid, "spoc1");
  1117. instance._prefs.get = pref => JSON.stringify({1: [1, 2, 7200003], 2: [3]});
  1118. // campaign 1 lifetime cap now reached (no spoc should be sent)
  1119. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  1120. assert.callCount(instance.store.dispatch, 4);
  1121. });
  1122. it("should maintain client-side MAX_LIFETIME_CAP", async () => {
  1123. let fetchStub = globals.sandbox.stub();
  1124. instance.dispatchRelevanceScore = () => {};
  1125. instance.dispatchSpocDone = () => {};
  1126. sectionsManagerStub.sections.set("topstories", {
  1127. options: {
  1128. show_spocs: true,
  1129. personalized: true,
  1130. stories_endpoint: "stories-endpoint",
  1131. },
  1132. });
  1133. globals.set("fetch", fetchStub);
  1134. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  1135. instance.getPocketState = () => {};
  1136. instance.dispatchPocketCta = () => {};
  1137. const response = {
  1138. "settings": {"spocsPerNewTabs": 1},
  1139. "recommendations": [{"guid": "rec1"}, {"guid": "rec2"}, {"guid": "rec3"}],
  1140. "spocs": [
  1141. {"id": "spoc1", "campaign_id": 1, "caps": {"lifetime": 501}},
  1142. ],
  1143. };
  1144. instance.store.getState = () => ({Sections: [{id: "topstories", rows: response.recommendations}], Prefs: {values: {showSponsored: true}}});
  1145. fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
  1146. await instance.onInit();
  1147. instance._prefs.get = pref => JSON.stringify({1: [...Array(500).keys()]});
  1148. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  1149. assert.notCalled(instance.store.dispatch);
  1150. });
  1151. });
  1152. describe("#update", () => {
  1153. it("should fetch stories after update interval", async () => {
  1154. await instance.onInit();
  1155. sinon.spy(instance, "fetchStories");
  1156. await instance.onAction({type: at.SYSTEM_TICK});
  1157. assert.notCalled(instance.fetchStories);
  1158. clock.tick(STORIES_UPDATE_TIME);
  1159. await instance.onAction({type: at.SYSTEM_TICK});
  1160. assert.calledOnce(instance.fetchStories);
  1161. });
  1162. it("should fetch topics after update interval", async () => {
  1163. await instance.onInit();
  1164. sinon.spy(instance, "fetchTopics");
  1165. await instance.onAction({type: at.SYSTEM_TICK});
  1166. assert.notCalled(instance.fetchTopics);
  1167. clock.tick(TOPICS_UPDATE_TIME);
  1168. await instance.onAction({type: at.SYSTEM_TICK});
  1169. assert.calledOnce(instance.fetchTopics);
  1170. });
  1171. it("should return updated stories and topics on system tick", async () => {
  1172. await instance.onInit();
  1173. sinon.spy(instance, "dispatchUpdateEvent");
  1174. const stories = [{"guid": "rec1"}, {"guid": "rec2"}, {"guid": "rec3"}];
  1175. const topics = [{"name": "topic1", "url": "url-topic1"}, {"name": "topic2", "url": "url-topic2"}];
  1176. clock.tick(TOPICS_UPDATE_TIME);
  1177. globals.sandbox.stub(instance, "fetchStories").resolves(stories);
  1178. globals.sandbox.stub(instance, "fetchTopics").resolves(topics);
  1179. await instance.onAction({type: at.SYSTEM_TICK});
  1180. assert.calledOnce(instance.dispatchUpdateEvent);
  1181. assert.calledWith(instance.dispatchUpdateEvent, false, {
  1182. rows: [{"guid": "rec1"}, {"guid": "rec2"}, {"guid": "rec3"}],
  1183. topics: [{"name": "topic1", "url": "url-topic1"}, {"name": "topic2", "url": "url-topic2"}],
  1184. read_more_endpoint: undefined,
  1185. });
  1186. });
  1187. it("should update domain affinities on idle-daily, if personalization preffed on", async () => {
  1188. instance.init();
  1189. instance.affinityProvider = undefined;
  1190. instance.cache.set = sinon.spy();
  1191. instance.observe("", "idle-daily");
  1192. assert.isUndefined(instance.affinityProvider);
  1193. instance.personalized = true;
  1194. instance.updateSettings({timeSegments: {}, domainAffinityParameterSets: {}});
  1195. clock.tick(MIN_DOMAIN_AFFINITIES_UPDATE_TIME);
  1196. await instance.observe("", "idle-daily");
  1197. assert.isDefined(instance.affinityProvider);
  1198. assert.calledOnce(instance.cache.set);
  1199. assert.calledWith(instance.cache.set, "domainAffinities",
  1200. Object.assign({}, instance.affinityProvider.getAffinities(), {"_timestamp": MIN_DOMAIN_AFFINITIES_UPDATE_TIME}));
  1201. });
  1202. it("should not update domain affinities too often", () => {
  1203. instance.init();
  1204. instance.affinityProvider = undefined;
  1205. instance.cache.set = sinon.spy();
  1206. instance.personalized = true;
  1207. instance.updateSettings({timeSegments: {}, domainAffinityParameterSets: {}});
  1208. clock.tick(MIN_DOMAIN_AFFINITIES_UPDATE_TIME);
  1209. instance.domainAffinitiesLastUpdated = Date.now();
  1210. instance.observe("", "idle-daily");
  1211. assert.isUndefined(instance.affinityProvider);
  1212. });
  1213. it("should send performance telemetry when updating domain affinities", async () => {
  1214. instance.getPocketState = () => {};
  1215. instance.dispatchPocketCta = () => {};
  1216. instance.init();
  1217. instance.personalized = true;
  1218. clock.tick(MIN_DOMAIN_AFFINITIES_UPDATE_TIME);
  1219. instance.updateSettings({timeSegments: {}, domainAffinityParameterSets: {}});
  1220. await instance.observe("", "idle-daily");
  1221. assert.calledOnce(instance.store.dispatch);
  1222. let [action] = instance.store.dispatch.firstCall.args;
  1223. assert.equal(action.type, at.TELEMETRY_PERFORMANCE_EVENT);
  1224. assert.equal(action.data.event, "topstories.domain.affinity.calculation.ms");
  1225. });
  1226. it("should add idle-daily observer right away, before waiting on init data", async () => {
  1227. const addObserver = globals.sandbox.stub();
  1228. globals.set("Services", {obs: {addObserver}});
  1229. const initPromise = instance.onInit();
  1230. assert.calledOnce(addObserver);
  1231. await initPromise;
  1232. });
  1233. it("should not call init and uninit if data doesn't match on options change ", () => {
  1234. sinon.spy(instance, "init");
  1235. sinon.spy(instance, "uninit");
  1236. instance.onAction({type: at.SECTION_OPTIONS_CHANGED, data: "foo"});
  1237. assert.notCalled(sectionsManagerStub.disableSection);
  1238. assert.notCalled(sectionsManagerStub.enableSection);
  1239. assert.notCalled(instance.init);
  1240. assert.notCalled(instance.uninit);
  1241. });
  1242. it("should call init and uninit on options change", async () => {
  1243. sinon.stub(instance, "clearCache").returns(Promise.resolve());
  1244. sinon.spy(instance, "init");
  1245. sinon.spy(instance, "uninit");
  1246. await instance.onAction({type: at.SECTION_OPTIONS_CHANGED, data: "topstories"});
  1247. assert.calledOnce(sectionsManagerStub.disableSection);
  1248. assert.calledOnce(sectionsManagerStub.enableSection);
  1249. assert.calledOnce(instance.clearCache);
  1250. assert.calledOnce(instance.init);
  1251. assert.calledOnce(instance.uninit);
  1252. });
  1253. it("should set LastUpdated to 0 on init", async () => {
  1254. instance.storiesLastUpdated = 1;
  1255. instance.topicsLastUpdated = 1;
  1256. await instance.onInit();
  1257. assert.equal(instance.storiesLastUpdated, 0);
  1258. assert.equal(instance.topicsLastUpdated, 0);
  1259. });
  1260. it("should filter spocs when link is blocked", async () => {
  1261. instance.spocs = [{"url": "not_blocked"}, {"url": "blocked"}];
  1262. await instance.onAction({type: at.PLACES_LINK_BLOCKED, data: {url: "blocked"}});
  1263. assert.deepEqual(instance.spocs, [{"url": "not_blocked"}]);
  1264. });
  1265. it("should reset domain affinity scores if version changed", async () => {
  1266. instance.init();
  1267. instance.personalized = true;
  1268. instance.resetDomainAffinityScores = sinon.spy();
  1269. instance.updateSettings({timeSegments: {}, domainAffinityParameterSets: {}, version: "1"});
  1270. clock.tick(MIN_DOMAIN_AFFINITIES_UPDATE_TIME);
  1271. await instance.observe("", "idle-daily");
  1272. assert.notCalled(instance.resetDomainAffinityScores);
  1273. instance.updateSettings({timeSegments: {}, domainAffinityParameterSets: {}, version: "2"});
  1274. assert.calledOnce(instance.resetDomainAffinityScores);
  1275. });
  1276. });
  1277. describe("#loadCachedData", () => {
  1278. it("should update section with cached stories and topics if available", async () => {
  1279. sectionsManagerStub.sections.set("topstories", {options: {stories_referrer: "referrer"}});
  1280. const stories = {
  1281. "_timestamp": 123,
  1282. "recommendations": [{
  1283. "id": "1",
  1284. "title": "title",
  1285. "excerpt": "description",
  1286. "image_src": "image-url",
  1287. "url": "rec-url",
  1288. "published_timestamp": "123",
  1289. "context": "trending",
  1290. "icon": "icon",
  1291. "item_score": 0.98,
  1292. }],
  1293. };
  1294. const transformedStories = [{
  1295. "guid": "1",
  1296. "type": "now",
  1297. "title": "title",
  1298. "context": "trending",
  1299. "icon": "icon",
  1300. "description": "description",
  1301. "image": "image-url",
  1302. "referrer": "referrer",
  1303. "url": "rec-url",
  1304. "hostname": "rec-url",
  1305. "min_score": 0,
  1306. "score": 0.98,
  1307. "spoc_meta": {},
  1308. }];
  1309. const topics = {
  1310. "_timestamp": 123,
  1311. "topics": [{"name": "topic1", "url": "url-topic1"}, {"name": "topic2", "url": "url-topic2"}],
  1312. };
  1313. instance.cache.get = () => ({stories, topics});
  1314. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  1315. await instance.onInit();
  1316. assert.calledOnce(sectionsManagerStub.updateSection);
  1317. assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {rows: transformedStories, topics: topics.topics, read_more_endpoint: undefined});
  1318. });
  1319. it("should NOT update section if there is no cached data", async () => {
  1320. instance.cache.get = () => ({});
  1321. globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
  1322. await instance.loadCachedData();
  1323. assert.notCalled(sectionsManagerStub.updateSection);
  1324. });
  1325. it("should use store rows if no stories sent to doContentUpdate", async () => {
  1326. instance.store = {
  1327. getState() {
  1328. return {
  1329. Sections: [{id: "topstories", rows: [1, 2, 3]}],
  1330. };
  1331. },
  1332. };
  1333. sinon.spy(instance, "dispatchUpdateEvent");
  1334. instance.doContentUpdate({}, false);
  1335. assert.calledOnce(instance.dispatchUpdateEvent);
  1336. assert.calledWith(instance.dispatchUpdateEvent, false, {rows: [1, 2, 3]});
  1337. });
  1338. it("should broadcast in doContentUpdate when updating from cache", async () => {
  1339. sectionsManagerStub.sections.set("topstories", {options: {stories_referrer: "referrer"}});
  1340. globals.set("NewTabUtils", {blockedLinks: {isBlocked: () => {}}});
  1341. const stories = {"recommendations": [{}]};
  1342. const topics = {"topics": [{}]};
  1343. sinon.spy(instance, "doContentUpdate");
  1344. instance.cache.get = () => ({stories, topics});
  1345. await instance.onInit();
  1346. assert.calledOnce(instance.doContentUpdate);
  1347. assert.calledWith(instance.doContentUpdate, {
  1348. stories: [{
  1349. context: undefined,
  1350. description: undefined,
  1351. guid: undefined,
  1352. hostname: undefined,
  1353. icon: undefined,
  1354. image: undefined,
  1355. min_score: 0,
  1356. referrer: "referrer",
  1357. score: 1,
  1358. spoc_meta: { },
  1359. title: undefined,
  1360. type: "trending",
  1361. url: undefined,
  1362. }],
  1363. topics: [{}],
  1364. }, true);
  1365. });
  1366. it("should initialize user domain affinity provider from cache if personalization is preffed on", async () => {
  1367. const domainAffinities = {
  1368. "parameterSets": {
  1369. "default": {
  1370. "recencyFactor": 0.4,
  1371. "frequencyFactor": 0.5,
  1372. "combinedDomainFactor": 0.5,
  1373. "perfectFrequencyVisits": 10,
  1374. "perfectCombinedDomainScore": 2,
  1375. "multiDomainBoost": 0.1,
  1376. "itemScoreFactor": 0,
  1377. },
  1378. },
  1379. "scores": {"a.com": 1, "b.com": 0.9},
  1380. "maxHistoryQueryResults": 1000,
  1381. "timeSegments": {},
  1382. "version": "v1",
  1383. };
  1384. instance.affinityProvider = undefined;
  1385. instance.cache.get = () => ({domainAffinities});
  1386. await instance.loadCachedData();
  1387. assert.isUndefined(instance.affinityProvider);
  1388. instance.personalized = true;
  1389. await instance.loadCachedData();
  1390. assert.isDefined(instance.affinityProvider);
  1391. assert.deepEqual(instance.affinityProvider.timeSegments, domainAffinities.timeSegments);
  1392. assert.equal(instance.affinityProvider.maxHistoryQueryResults, domainAffinities.maxHistoryQueryResults);
  1393. assert.deepEqual(instance.affinityProvider.parameterSets, domainAffinities.parameterSets);
  1394. assert.deepEqual(instance.affinityProvider.scores, domainAffinities.scores);
  1395. assert.deepEqual(instance.affinityProvider.version, domainAffinities.version);
  1396. });
  1397. it("should clear domain affinity cache when history is cleared", () => {
  1398. instance.cache.set = sinon.spy();
  1399. instance.affinityProvider = {};
  1400. instance.personalized = true;
  1401. instance.onAction({type: at.PLACES_HISTORY_CLEARED});
  1402. assert.calledWith(instance.cache.set, "domainAffinities", {});
  1403. assert.isUndefined(instance.affinityProvider);
  1404. });
  1405. });
  1406. describe("#pocket", () => {
  1407. it("should call getPocketState when hitting NEW_TAB_REHYDRATED", () => {
  1408. instance.getPocketState = sinon.spy();
  1409. instance.onAction({type: at.NEW_TAB_REHYDRATED, meta: {fromTarget: {}}});
  1410. assert.calledOnce(instance.getPocketState);
  1411. assert.calledWith(instance.getPocketState, {});
  1412. });
  1413. it("should call dispatch in getPocketState", () => {
  1414. const isUserLoggedIn = sinon.spy();
  1415. globals.set("pktApi", {isUserLoggedIn});
  1416. instance.getPocketState({});
  1417. assert.calledOnce(instance.store.dispatch);
  1418. const [action] = instance.store.dispatch.firstCall.args;
  1419. assert.equal(action.type, "POCKET_LOGGED_IN");
  1420. assert.calledOnce(isUserLoggedIn);
  1421. });
  1422. it("should call dispatchPocketCta when hitting onInit", async () => {
  1423. instance.dispatchPocketCta = sinon.spy();
  1424. await instance.onInit();
  1425. assert.calledOnce(instance.dispatchPocketCta);
  1426. assert.calledWith(instance.dispatchPocketCta, JSON.stringify({
  1427. cta_button: "",
  1428. cta_text: "",
  1429. cta_url: "",
  1430. use_cta: false,
  1431. }), false);
  1432. });
  1433. it("should call dispatch in dispatchPocketCta", () => {
  1434. instance.dispatchPocketCta(JSON.stringify({use_cta: true}), false);
  1435. assert.calledOnce(instance.store.dispatch);
  1436. const [action] = instance.store.dispatch.firstCall.args;
  1437. assert.equal(action.type, "POCKET_CTA");
  1438. assert.equal(action.data.use_cta, true);
  1439. });
  1440. it("should call dispatchPocketCta with a pocketCta pref change", () => {
  1441. instance.dispatchPocketCta = sinon.spy();
  1442. instance.onAction({
  1443. type: at.PREF_CHANGED,
  1444. data: {
  1445. name: "pocketCta",
  1446. value: JSON.stringify({
  1447. cta_button: "",
  1448. cta_text: "",
  1449. cta_url: "",
  1450. use_cta: false,
  1451. }),
  1452. },
  1453. });
  1454. assert.calledOnce(instance.dispatchPocketCta);
  1455. assert.calledWith(instance.dispatchPocketCta, JSON.stringify({
  1456. cta_button: "",
  1457. cta_text: "",
  1458. cta_url: "",
  1459. use_cta: false,
  1460. }), true);
  1461. });
  1462. });
  1463. it("should call uninit and init on disabling of showSponsored pref", async () => {
  1464. sinon.stub(instance, "clearCache").returns(Promise.resolve());
  1465. sinon.stub(instance, "uninit");
  1466. sinon.stub(instance, "init");
  1467. await instance.onAction({type: at.PREF_CHANGED, data: {name: "showSponsored", value: false}});
  1468. assert.calledOnce(instance.clearCache);
  1469. assert.calledOnce(instance.uninit);
  1470. assert.calledOnce(instance.init);
  1471. });
  1472. });