tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 });