test_WeatherFeed.js (16188B)
1 /* Any copyright is dedicated to the Public Domain. 2 http://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 "use strict"; 5 6 ChromeUtils.defineESModuleGetters(this, { 7 actionCreators: "resource://newtab/common/Actions.mjs", 8 actionTypes: "resource://newtab/common/Actions.mjs", 9 sinon: "resource://testing-common/Sinon.sys.mjs", 10 GeolocationTestUtils: 11 "resource://testing-common/GeolocationTestUtils.sys.mjs", 12 MerinoTestUtils: "resource://testing-common/MerinoTestUtils.sys.mjs", 13 WeatherFeed: "resource://newtab/lib/WeatherFeed.sys.mjs", 14 Region: "resource://gre/modules/Region.sys.mjs", 15 }); 16 17 const { WEATHER_SUGGESTION } = MerinoTestUtils; 18 GeolocationTestUtils.init(this); 19 20 const WEATHER_ENABLED = "browser.newtabpage.activity-stream.showWeather"; 21 const SYS_WEATHER_ENABLED = 22 "browser.newtabpage.activity-stream.system.showWeather"; 23 24 add_task(async function test_construction() { 25 let sandbox = sinon.createSandbox(); 26 sandbox.stub(WeatherFeed.prototype, "PersistentCache").returns({ 27 set: () => {}, 28 get: () => {}, 29 }); 30 31 let feed = new WeatherFeed(); 32 33 info("WeatherFeed constructor should create initial values"); 34 35 Assert.ok(feed, "Could construct a WeatherFeed"); 36 Assert.strictEqual(feed.loaded, false, "WeatherFeed is not loaded"); 37 Assert.strictEqual(feed.merino, null, "merino is initialized as null"); 38 Assert.strictEqual( 39 feed.suggestions.length, 40 0, 41 "suggestions is initialized as a array with length of 0" 42 ); 43 Assert.strictEqual( 44 feed.fetchTimer, 45 null, 46 "fetchTimer is initialized as null" 47 ); 48 sandbox.restore(); 49 }); 50 51 add_task(async function test_checkOptInRegion() { 52 let sandbox = sinon.createSandbox(); 53 54 sandbox.stub(WeatherFeed.prototype, "PersistentCache").returns({ 55 set: () => {}, 56 get: () => {}, 57 }); 58 59 let feed = new WeatherFeed(); 60 61 feed.store = { 62 dispatch: sinon.spy(), 63 getState() { 64 return { Prefs: { values: {} } }; 65 }, 66 }; 67 68 sandbox.stub(feed, "isEnabled").returns(true); 69 70 // First case: If home region is in the opt-in list, showWeatherOptIn should be true 71 // Region._setHomeRegion() is the supported way to control region in tests: 72 // https://firefox-source-docs.mozilla.org/toolkit/modules/toolkit_modules/Region.html#testing 73 // We used false here because that second argument is a change observer that will fire an event. 74 // So keeping it false silently sets the region for our test 75 Region._setHomeRegion("FR", false); 76 let resultTrue = await feed.checkOptInRegion(); 77 78 Assert.strictEqual( 79 resultTrue, 80 true, 81 "Returns true for region in opt-in list" 82 ); 83 Assert.ok( 84 feed.store.dispatch.calledWith( 85 actionCreators.SetPref("system.showWeatherOptIn", true) 86 ), 87 "Dispatch sets system.showWeatherOptIn to true when region is in opt-in list" 88 ); 89 90 // Second case: If home region is not in the opt-in list, showWeatherOptIn should be false 91 Region._setHomeRegion("ZZ", false); 92 let resultFalse = await feed.checkOptInRegion(); 93 94 Assert.strictEqual( 95 resultFalse, 96 false, 97 "Returns false for region not found in opt-in list" 98 ); 99 Assert.ok( 100 feed.store.dispatch.calledWith( 101 actionCreators.SetPref("system.showWeatherOptIn", false) 102 ), 103 "Dispatch sets system.showWeatherOptIn to false when region is not in opt-in list" 104 ); 105 106 sandbox.restore(); 107 }); 108 109 add_task(async function test_onAction_INIT() { 110 let sandbox = sinon.createSandbox(); 111 sandbox.stub(WeatherFeed.prototype, "MerinoClient").returns({ 112 get: () => [WEATHER_SUGGESTION], 113 on: () => {}, 114 }); 115 sandbox.stub(WeatherFeed.prototype, "PersistentCache").returns({ 116 set: () => {}, 117 get: () => {}, 118 }); 119 const dateNowTestValue = 1; 120 sandbox.stub(WeatherFeed.prototype, "Date").returns({ 121 now: () => dateNowTestValue, 122 }); 123 124 let feed = new WeatherFeed(); 125 let locationData = { 126 city: "testcity", 127 adminArea: "", 128 country: "", 129 }; 130 131 Services.prefs.setBoolPref(WEATHER_ENABLED, true); 132 Services.prefs.setBoolPref(SYS_WEATHER_ENABLED, true); 133 134 sandbox.stub(feed, "isEnabled").returns(true); 135 136 sandbox.stub(feed, "_fetchHelper").resolves([WEATHER_SUGGESTION]); 137 feed.locationData = locationData; 138 feed.store = { 139 dispatch: sinon.spy(), 140 getState() { 141 return this.state; 142 }, 143 state: { 144 Prefs: { 145 values: { 146 "weather.query": "348794", 147 }, 148 }, 149 }, 150 }; 151 152 info("WeatherFeed.onAction INIT should initialize Weather"); 153 154 await feed.onAction({ 155 type: actionTypes.INIT, 156 }); 157 158 Assert.equal(feed.store.dispatch.callCount, 2); 159 Assert.ok( 160 feed.store.dispatch.calledWith( 161 actionCreators.BroadcastToContent({ 162 type: actionTypes.WEATHER_UPDATE, 163 data: { 164 suggestions: [WEATHER_SUGGESTION], 165 lastUpdated: dateNowTestValue, 166 locationData, 167 }, 168 }) 169 ) 170 ); 171 Services.prefs.clearUserPref(WEATHER_ENABLED); 172 sandbox.restore(); 173 }); 174 175 // Test if location lookup was successful 176 add_task(async function test_onAction_opt_in_location_success() { 177 let sandbox = sinon.createSandbox(); 178 179 sandbox.stub(WeatherFeed.prototype, "PersistentCache").returns({ 180 set: () => {}, 181 get: () => {}, 182 }); 183 184 let feed = new WeatherFeed(); 185 186 feed.store = { 187 dispatch: sinon.spy(), 188 getState() { 189 return { Prefs: { values: {} } }; 190 }, 191 }; 192 193 // Stub _fetchNormalizedLocation() to simulate a successful lookup 194 sandbox.stub(feed, "_fetchNormalizedLocation").resolves({ 195 localized_name: "Testville", 196 administrative_area: "Paris", 197 country: "FR", 198 key: "12345", 199 }); 200 201 await feed.onAction({ type: actionTypes.WEATHER_USER_OPT_IN_LOCATION }); 202 203 Assert.ok( 204 feed.store.dispatch.calledWith( 205 actionCreators.SetPref("weather.optInAccepted", true) 206 ) 207 ); 208 Assert.ok( 209 feed.store.dispatch.calledWith( 210 actionCreators.SetPref("weather.optInDisplayed", false) 211 ) 212 ); 213 214 // Assert location data broadcasted to content 215 Assert.ok( 216 feed.store.dispatch.calledWith( 217 actionCreators.BroadcastToContent({ 218 type: actionTypes.WEATHER_LOCATION_DATA_UPDATE, 219 data: { 220 city: "Testville", 221 adminName: "Paris", 222 country: "FR", 223 }, 224 }) 225 ), 226 "Broadcasts WEATHER_LOCATION_DATA_UPDATE with normalized location data" 227 ); 228 229 Assert.ok( 230 feed.store.dispatch.calledWith( 231 actionCreators.SetPref("weather.query", "12345") 232 ), 233 "Sets weather.query pref from location key" 234 ); 235 236 sandbox.restore(); 237 }); 238 239 // Test if no location was found 240 add_task(async function test_onAction_opt_in_no_location_found() { 241 let sandbox = sinon.createSandbox(); 242 243 sandbox.stub(WeatherFeed.prototype, "PersistentCache").returns({ 244 set: () => {}, 245 get: () => {}, 246 }); 247 248 let feed = new WeatherFeed(); 249 250 feed.store = { 251 dispatch: sinon.spy(), 252 getState() { 253 return { Prefs: { values: {} } }; 254 }, 255 }; 256 257 // Test that _fetchNormalizedLocation doesn't return a location 258 sandbox.stub(feed, "_fetchNormalizedLocation").resolves(null); 259 260 await feed.onAction({ type: actionTypes.WEATHER_USER_OPT_IN_LOCATION }); 261 262 // Ensure the pref flips always happens so user won’t see the opt-in again 263 Assert.ok( 264 feed.store.dispatch.calledWith( 265 actionCreators.SetPref("weather.optInAccepted", true) 266 ) 267 ); 268 Assert.ok( 269 feed.store.dispatch.calledWith( 270 actionCreators.SetPref("weather.optInDisplayed", false) 271 ) 272 ); 273 274 Assert.ok( 275 !feed.store.dispatch.calledWithMatch( 276 actionCreators.BroadcastToContent({ 277 type: actionTypes.WEATHER_LOCATION_DATA_UPDATE, 278 }) 279 ), 280 "Doesn't broadcast location data if location not found" 281 ); 282 283 Assert.ok( 284 !feed.store.dispatch.calledWith( 285 actionCreators.SetPref("weather.query", sinon.match.any) 286 ), 287 "Does not set weather.query if no detected location" 288 ); 289 290 sandbox.restore(); 291 }); 292 293 // Test fetching weather information using GeolocationUtils.geolocation() 294 add_task(async function test_fetch_weather_with_geolocation() { 295 const TEST_DATA = [ 296 { 297 geolocation: { 298 country_code: "US", 299 region_code: "CA", 300 region: "Califolnia", 301 city: "San Francisco", 302 }, 303 expected: { 304 country: "US", 305 region: "CA", 306 city: "San Francisco", 307 }, 308 }, 309 { 310 geolocation: { 311 country_code: "JP", 312 region_code: "14", 313 region: "Kanagawa", 314 city: "", 315 }, 316 expected: { 317 country: "JP", 318 region: "14", 319 city: "Kanagawa", 320 }, 321 }, 322 { 323 geolocation: { 324 country_code: "TestCountry", 325 region_code: "", 326 region: "TestRegion", 327 city: "TestCity", 328 }, 329 expected: { 330 country: "TestCountry", 331 region: "TestRegion", 332 city: "TestCity", 333 }, 334 }, 335 { 336 // Test city-state fallback: Singapore (no region field) 337 geolocation: { 338 country_code: "SG", 339 region_code: null, 340 region: null, 341 city: "Singapore", 342 }, 343 expected: { 344 country: "SG", 345 region: "Singapore", // City used as fallback for region 346 city: "Singapore", 347 }, 348 }, 349 { 350 // Test city-state fallback: Monaco (no region field) 351 geolocation: { 352 country_code: "MC", 353 city: "Monaco", 354 }, 355 expected: { 356 country: "MC", 357 region: "Monaco", // City used as fallback for region 358 city: "Monaco", 359 }, 360 }, 361 { 362 geolocation: { 363 country_code: "TestCountry", 364 }, 365 // Missing region and city - request should be blocked 366 expected: false, 367 }, 368 { 369 geolocation: { 370 region_code: "TestRegionCode", 371 }, 372 // Missing country and city - request should be blocked 373 expected: false, 374 }, 375 { 376 geolocation: { 377 region: "TestRegion", 378 }, 379 // Missing country - request should be blocked 380 expected: false, 381 }, 382 { 383 geolocation: { 384 city: "TestCity", 385 }, 386 // Missing country and region - request should be blocked 387 expected: false, 388 }, 389 { 390 geolocation: {}, 391 // Empty geolocation - request should be blocked 392 expected: false, 393 }, 394 { 395 geolocation: null, 396 expected: false, 397 }, 398 ]; 399 400 for (let { geolocation, expected } of TEST_DATA) { 401 info(`Test for ${JSON.stringify(geolocation)}`); 402 403 let sandbox = sinon.createSandbox(); 404 sandbox.stub(WeatherFeed.prototype, "PersistentCache").returns({ 405 set: () => {}, 406 get: () => {}, 407 }); 408 409 let feed = new WeatherFeed(); 410 sandbox.stub(feed, "isEnabled").returns(true); 411 feed.store = { 412 dispatch: sinon.spy(), 413 getState() { 414 return { Prefs: { values: {} } }; 415 }, 416 }; 417 feed.merino = { fetch: () => {} }; 418 419 // Stub merino client 420 let stub = sandbox.stub(feed.merino, "fetch").resolves(["result"]); 421 let cleanupGeolocationStub = 422 GeolocationTestUtils.stubGeolocation(geolocation); 423 424 await feed.onAction({ type: actionTypes.SYSTEM_TICK }); 425 426 if (expected) { 427 sinon.assert.calledOnce(stub); 428 sinon.assert.calledWith(stub, { 429 otherParams: { request_type: "weather", source: "newtab", ...expected }, 430 providers: ["accuweather"], 431 query: "", 432 timeoutMs: 7000, 433 }); 434 } else { 435 sinon.assert.notCalled(stub); 436 } 437 438 await cleanupGeolocationStub(); 439 sandbox.restore(); 440 } 441 }); 442 443 // Test detecting location using GeolocationUtils.geolocation() 444 add_task(async function test_detect_location_with_geolocation() { 445 const TEST_DATA = [ 446 { 447 geolocation: { 448 city: "San Francisco", 449 }, 450 expected: "San Francisco", 451 }, 452 { 453 geolocation: { 454 city: "", 455 region: "Yokohama", 456 }, 457 expected: "Yokohama", 458 }, 459 { 460 geolocation: { 461 region: "Tokyo", 462 }, 463 expected: "Tokyo", 464 }, 465 { 466 geolocation: { 467 city: "", 468 region: "", 469 }, 470 expected: false, 471 }, 472 { 473 geolocation: {}, 474 expected: false, 475 }, 476 { 477 geolocation: null, 478 expected: false, 479 }, 480 ]; 481 for (let { geolocation, expected } of TEST_DATA) { 482 info(`Test for ${JSON.stringify(geolocation)}`); 483 484 let sandbox = sinon.createSandbox(); 485 sandbox.stub(WeatherFeed.prototype, "PersistentCache").returns({ 486 set: () => {}, 487 get: () => {}, 488 }); 489 490 let feed = new WeatherFeed(); 491 feed.store = { 492 dispatch: sinon.spy(), 493 getState() { 494 return { Prefs: { values: {} } }; 495 }, 496 }; 497 feed.merino = { fetch: () => {} }; 498 499 // Stub merino client 500 let stub = sandbox.stub(feed.merino, "fetch").resolves(null); 501 // Stub geolocation 502 let cleanupGeolocationStub = 503 GeolocationTestUtils.stubGeolocation(geolocation); 504 await feed.onAction({ type: actionTypes.WEATHER_USER_OPT_IN_LOCATION }); 505 506 if (expected) { 507 sinon.assert.calledOnce(stub); 508 sinon.assert.calledWith(stub, { 509 otherParams: { request_type: "location", source: "newtab" }, 510 providers: ["accuweather"], 511 query: expected, 512 timeoutMs: 7000, 513 }); 514 } else { 515 sinon.assert.notCalled(stub); 516 } 517 518 await cleanupGeolocationStub(); 519 sandbox.restore(); 520 } 521 }); 522 523 function setupFetchHelperHarness( 524 sandbox, 525 outcomes /* e.g. ['reject','resolve'] */ 526 ) { 527 // Prevent the “next fetch” scheduling inside fetchHelper(). 528 sandbox.stub(WeatherFeed.prototype, "restartFetchTimer").returns(undefined); 529 530 // Stub the timeout and capture the retry callback. 531 let timeoutCallback = null; 532 const setTimeoutStub = sandbox 533 .stub(WeatherFeed.prototype, "setTimeout") 534 .callsFake(cb => { 535 timeoutCallback = cb; 536 return 1; 537 }); 538 539 const feed = new WeatherFeed(); 540 541 // Minimal store so fetchHelper can read prefs. 542 feed.store = { 543 dispatch: sinon.spy(), 544 getState() { 545 return { Prefs: { values: {} } }; 546 }, 547 }; 548 549 const fetchStub = sinon.stub(); 550 551 // Fail or pass each fetch. 552 outcomes.forEach((outcome, index) => { 553 if (outcome === "reject") { 554 fetchStub.onCall(index).rejects(new Error(`fail${index}`)); 555 } else if (outcome === "resolve") { 556 fetchStub.onCall(index).resolves([{ city_name: "RetryCity" }]); 557 } 558 }); 559 feed.merino = { fetch: fetchStub }; 560 561 return { 562 feed, 563 setTimeoutStub, 564 triggerRetry: () => timeoutCallback && timeoutCallback(), 565 }; 566 } 567 568 add_task(async function test_fetchHelper_retry_resolve() { 569 const sandbox = sinon.createSandbox(); 570 571 const { feed, setTimeoutStub, triggerRetry } = setupFetchHelperHarness( 572 sandbox, 573 ["reject", "resolve"] 574 ); 575 576 // After retry success, fetchHelper should resolve to RetryCity. 577 const promise = feed._fetchHelper(1, "q"); 578 579 // Let the first attempt run and schedule the retry. 580 await Promise.resolve(); 581 582 Assert.equal(feed.merino.fetch.callCount, 1); 583 Assert.equal(setTimeoutStub.callCount, 1); 584 Assert.ok( 585 setTimeoutStub.calledWith(sinon.match.func, 60 * 1000), 586 "retry waits 60s (virtually)" 587 ); 588 589 // Fire the retry. 590 triggerRetry(); 591 const results = await promise; 592 593 Assert.equal(feed.merino.fetch.callCount, 2, "retried exactly once"); 594 Assert.deepEqual( 595 results, 596 [{ city_name: "RetryCity" }], 597 "returned retry result" 598 ); 599 600 sandbox.restore(); 601 }); 602 603 add_task(async function test_fetchHelper_retry_reject() { 604 const sandbox = sinon.createSandbox(); 605 606 const { feed, setTimeoutStub, triggerRetry } = setupFetchHelperHarness( 607 sandbox, 608 ["reject", "reject"] 609 ); 610 611 // After retry also fails, fetchHelper should resolve to []. 612 const promise = feed._fetchHelper(1, "q"); 613 614 // Let the first attempt run and schedule the retry. 615 await Promise.resolve(); 616 617 Assert.equal(feed.merino.fetch.callCount, 1); 618 Assert.equal(setTimeoutStub.callCount, 1); 619 Assert.ok( 620 setTimeoutStub.calledWith(sinon.match.func, 60 * 1000), 621 "retry waits 60s (virtually)" 622 ); 623 624 // Fire the retry. 625 triggerRetry(); 626 const results = await promise; 627 628 Assert.equal( 629 feed.merino.fetch.callCount, 630 2, 631 "retried exactly once then gave up" 632 ); 633 Assert.deepEqual(results, [], "returns empty array after exhausting retries"); 634 635 sandbox.restore(); 636 });