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