Bug 1156549 - Allow ramp up time for campaigns with strict start/stop times [r=adw]
authorMaxim Zhilyaev <mzhilyaev@mozilla.com>
Wed, 06 May 2015 12:09:45 -0700
changeset 242660 cd19fda2d5f84b223342d96af5c311ed23a9e25d
parent 242659 eac6ac60b5e648bd0bb52a8a085f73491bf66faa
child 242661 377431f8f40807b49a7859edd2c94da332e3410e
push id28705
push usercbook@mozilla.com
push dateThu, 07 May 2015 13:34:47 +0000
treeherdermozilla-central@6c8b6e1d328f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersadw
bugs1156549
milestone40.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1156549 - Allow ramp up time for campaigns with strict start/stop times [r=adw]
browser/docs/DirectoryLinksProvider.rst
browser/modules/DirectoryLinksProvider.jsm
browser/modules/test/xpcshell/test_DirectoryLinksProvider.js
--- a/browser/docs/DirectoryLinksProvider.rst
+++ b/browser/docs/DirectoryLinksProvider.rst
@@ -144,16 +144,17 @@ Below is an example directory source fil
                   "mozilla.org",
                   "planet.mozilla.org",
                   "quality.mozilla.org",
                   "support.mozilla.org",
                   "treeherder.mozilla.org",
                   "wiki.mozilla.org"
               ],
               "imageURI": "https://tiles.cdn.mozilla.net/images/9ee2b265678f2775de2e4bf680df600b502e6038.3875.png",
+              "time_limits": {"start": "2014-01-01T00:00:00.000Z", "end": "2014-02-01T00:00:00.000Z"},
               "title": "Thanks for testing!",
               "type": "affiliate",
               "url": "https://www.mozilla.com/firefox/tiles"
           }
       ]
   }
 
 Link Object
@@ -177,16 +178,19 @@ Suggested Link Object Extras
 ----------------------------
 
 A suggested link has additional values:
 
 - ``frecent_sites`` - array of strings of the sites that can trigger showing a
   Suggested Tile if the user has the site in one of the top 100 most-frecent
   pages. Only preapproved array of strings that are hardcoded into the
   DirectoryLinksProvider module are allowed.
+- ``time_limits`` - an object consisting of start and end timestamps specifying
+  when a Suggested Tile may start and has to stop showing in the newtab.
+  The timestamp is expected in ISO_8601 format: '2014-01-10T20:00:00.000Z'
 
 The preapproved arrays follow a policy for determining what topic grouping is
 allowed as well as the composition of a grouping. The topics are broad
 uncontroversial categories, e.g., Mobile Phone, News, Technology, Video Game,
 Web Development. There are at least 5 sites within a grouping, and as many
 popular sites relevant to the topic are included to avoid having one site be
 clearly dominant. These requirements provide some deniability of which site
 actually triggered a suggestion during ping reporting, so it's more difficult to
--- a/browser/modules/DirectoryLinksProvider.jsm
+++ b/browser/modules/DirectoryLinksProvider.jsm
@@ -245,16 +245,17 @@ let DirectoryLinksProvider = {
   _cacheSuggestedLinks: function(link) {
     if (!link.frecent_sites || "sponsored" == link.type) {
       // Don't cache links that don't have the expected 'frecent_sites' or are sponsored.
       return;
     }
     for (let suggestedSite of link.frecent_sites) {
       let suggestedMap = this._suggestedLinks.get(suggestedSite) || new Map();
       suggestedMap.set(link.url, link);
+      this._setupStartEndTime(link);
       this._suggestedLinks.set(suggestedSite, suggestedMap);
     }
   },
 
   _fetchAndCacheLinks: function DirectoryLinksProvider_fetchAndCacheLinks(uri) {
     // Replace with the same display locale used for selecting links data
     uri = uri.replace("%LOCALE%", this.locale);
     uri = uri.replace("%CHANNEL%", UpdateChannel.get());
@@ -361,16 +362,107 @@ let DirectoryLinksProvider = {
     },
     error => {
       Cu.reportError(error);
       return emptyOutput;
     });
   },
 
   /**
+   * Translates link.time_limits to UTC miliseconds and sets
+   * link.startTime and link.endTime properties in link object
+   */
+  _setupStartEndTime: function DirectoryLinksProvider_setupStartEndTime(link) {
+    // set start/end limits. Use ISO_8601 format: '2014-01-10T20:20:20.600Z'
+    // (details here http://en.wikipedia.org/wiki/ISO_8601)
+    // Note that if timezone is missing, FX will interpret as local time
+    // meaning that the server can sepecify any time, but if the capmaign
+    // needs to start at same time across multiple timezones, the server
+    // omits timezone indicator
+    if (!link.time_limits) {
+      return;
+    }
+
+    let parsedTime;
+    if (link.time_limits.start) {
+      parsedTime = Date.parse(link.time_limits.start);
+      if (parsedTime && !isNaN(parsedTime)) {
+        link.startTime = parsedTime;
+      }
+    }
+    if (link.time_limits.end) {
+      parsedTime = Date.parse(link.time_limits.end);
+      if (parsedTime && !isNaN(parsedTime)) {
+        link.endTime = parsedTime;
+      }
+    }
+  },
+
+  /*
+   * Handles campaign timeout
+   */
+  _onCampaignTimeout: function DirectoryLinksProvider_onCampaignTimeout() {
+    // _campaignTimeoutID is invalid here, so just set it to null
+    this._campaignTimeoutID = null;
+    this._updateSuggestedTile();
+  },
+
+  /*
+   * Clears capmpaign timeout
+   */
+  _clearCampaignTimeout: function DirectoryLinksProvider_clearCampaignTimeout() {
+    if (this._campaignTimeoutID) {
+      clearTimeout(this._campaignTimeoutID);
+      this._campaignTimeoutID = null;
+    }
+  },
+
+  /**
+   * Setup capmpaign timeout to recompute suggested tiles upon
+   * reaching soonest start or end time for the campaign
+   * @param timeout in milliseconds
+   */
+  _setupCampaignTimeCheck: function DirectoryLinksProvider_setupCampaignTimeCheck(timeout) {
+    // sanity check
+    if (!timeout || timeout <= 0) {
+      return;
+    }
+    this._clearCampaignTimeout();
+    // setup next timeout
+    this._campaignTimeoutID = setTimeout(this._onCampaignTimeout.bind(this), timeout);
+  },
+
+  /**
+   * Test link for campaign time limits: checks if link falls within start/end time
+   * and returns an object containing a use flag and the timeoutDate milliseconds
+   * when the link has to be re-checked for campaign start-ready or end-reach
+   * @param link
+   * @return object {use: true or false, timeoutDate: milliseconds or null}
+   */
+  _testLinkForCampaignTimeLimits: function DirectoryLinksProvider_testLinkForCampaignTimeLimits(link) {
+    let currentTime = Date.now();
+    // test for start time first
+    if (link.startTime && link.startTime > currentTime) {
+      // not yet ready for start
+      return {use: false, timeoutDate: link.startTime};
+    }
+    // otherwise check for end time
+    if (link.endTime) {
+      // passed end time
+      if (link.endTime <= currentTime) {
+        return {use: false};
+      }
+      // otherwise link is still ok, but we need to set timeoutDate
+      return {use: true, timeoutDate: link.endTime};
+    }
+    // if we are here, the link is ok and no timeoutDate needed
+    return {use: true};
+  },
+
+  /**
    * Report some action on a newtab page (view, click)
    * @param sites Array of sites shown on newtab page
    * @param action String of the behavior to report
    * @param triggeringSiteIndex optional Int index of the site triggering action
    * @return download promise
    */
   reportSitesAction: function DirectoryLinksProvider_reportSitesAction(sites, action, triggeringSiteIndex) {
     // Check if the suggested tile was shown
@@ -485,16 +577,17 @@ let DirectoryLinksProvider = {
    * @param aCallback The function that the array of links is passed to.
    */
   getLinks: function DirectoryLinksProvider_getLinks(aCallback) {
     this._readDirectoryLinksFile().then(rawLinks => {
       // Reset the cache of suggested tiles and enhanced images for this new set of links
       this._enhancedLinks.clear();
       this._frequencyCaps.clear();
       this._suggestedLinks.clear();
+      this._clearCampaignTimeout();
 
       let validityFilter = function(link) {
         // Make sure the link url is allowed and images too if they exist
         return this.isURLAllowed(link.url, ALLOWED_LINK_SCHEMES) &&
                this.isURLAllowed(link.imageURI, ALLOWED_IMAGE_SCHEMES) &&
                this.isURLAllowed(link.enhancedImageURI, ALLOWED_IMAGE_SCHEMES);
       }.bind(this);
 
@@ -700,37 +793,55 @@ let DirectoryLinksProvider = {
       return;
     }
 
     // Create a flat list of all possible links we can show as suggested.
     // Note that many top sites may map to the same suggested links, but we only
     // want to count each suggested link once (based on url), thus possibleLinks is a map
     // from url to suggestedLink. Thus, each link has an equal chance of being chosen at
     // random from flattenedLinks if it appears only once.
+    let nextTimeout;
     let possibleLinks = new Map();
     let targetedSites = new Map();
     this._topSitesWithSuggestedLinks.forEach(topSiteWithSuggestedLink => {
       let suggestedLinksMap = this._suggestedLinks.get(topSiteWithSuggestedLink);
       suggestedLinksMap.forEach((suggestedLink, url) => {
         // Skip this link if we've shown it too many times already
         if (this._frequencyCaps.get(url) <= 0) {
           return;
         }
 
+        // as we iterate suggestedLinks, check for campaign start/end
+        // time limits, and set nextTimeout to the closest timestamp
+        let {use, timeoutDate} = this._testLinkForCampaignTimeLimits(suggestedLink);
+        // update nextTimeout is necessary
+        if (timeoutDate && (!nextTimeout || nextTimeout > timeoutDate)) {
+          nextTimeout = timeoutDate;
+        }
+        // Skip link if it falls outside campaign time limits
+        if (!use) {
+          return;
+        }
+
         possibleLinks.set(url, suggestedLink);
 
         // Keep a map of URL to targeted sites. We later use this to show the user
         // what site they visited to trigger this suggestion.
         if (!targetedSites.get(url)) {
           targetedSites.set(url, []);
         }
         targetedSites.get(url).push(topSiteWithSuggestedLink);
       })
     });
 
+    // setup timeout check for starting or ending campaigns
+    if (nextTimeout) {
+      this._setupCampaignTimeCheck(nextTimeout - Date.now());
+    }
+
     // We might have run out of possible links to show
     let numLinks = possibleLinks.size;
     if (numLinks == 0) {
       return;
     }
 
     let flattenedLinks = [...possibleLinks.values()];
 
--- a/browser/modules/test/xpcshell/test_DirectoryLinksProvider.js
+++ b/browser/modules/test/xpcshell/test_DirectoryLinksProvider.js
@@ -1221,8 +1221,181 @@ add_task(function test_DirectoryLinksPro
 
   // Turn off DNT header
   Services.prefs.clearUserPref("privacy.donottrackheader.enabled");
   checkDefault(true);
 
   // Clean up
   Services.prefs.clearUserPref("privacy.donottrackheader.value");
 });
+
+add_task(function test_timeSensetiveSuggestedTiles() {
+  // make tile json with start and end dates
+  let testStartTime = Date.now();
+  // start date is now + 1 seconds
+  let startDate = new Date(testStartTime + 1000);
+  // end date is now + 3 seconds
+  let endDate = new Date(testStartTime + 3000);
+  let suggestedTile = Object.assign({
+    time_limits: {
+      start: startDate.toISOString(),
+      end: endDate.toISOString(),
+    }
+  }, suggestedTile1);
+
+  // Initial setup
+  let topSites = ["site0.com", "1040.com", "site2.com", "hrblock.com", "site4.com", "freetaxusa.com", "site6.com"];
+  let data = {"suggested": [suggestedTile], "directory": [someOtherSite]};
+  let dataURI = 'data:application/json,' + JSON.stringify(data);
+
+  let testObserver = new TestTimingRun();
+  DirectoryLinksProvider.addObserver(testObserver);
+
+  let origGetFrecentSitesName = DirectoryLinksProvider.getFrecentSitesName;
+  DirectoryLinksProvider.getFrecentSitesName = () => "";
+
+  yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+  let links = yield fetchData();
+
+  let origIsTopPlacesSite = NewTabUtils.isTopPlacesSite;
+  NewTabUtils.isTopPlacesSite = function(site) {
+    return topSites.indexOf(site) >= 0;
+  }
+
+  let origGetProviderLinks = NewTabUtils.getProviderLinks;
+  NewTabUtils.getProviderLinks = function(provider) {
+    return links;
+  }
+
+  let origCurrentTopSiteCount = DirectoryLinksProvider._getCurrentTopSiteCount;
+  DirectoryLinksProvider._getCurrentTopSiteCount = () => 8;
+
+  do_check_eq(DirectoryLinksProvider._updateSuggestedTile(), undefined);
+
+  // this tester will fire twice: when start limit is reached and when tile link
+  // is removed upon end of the campaign, in which case deleteFlag will be set
+  function TestTimingRun() {
+    this.promise = new Promise(resolve => {
+      this.onLinkChanged = (directoryLinksProvider, link, ignoreFlag, deleteFlag) => {
+        // if we are not deleting, add link to links, so we can catch it's removal
+        if (!deleteFlag) {
+          links.unshift(link);
+        }
+
+        isIdentical([...DirectoryLinksProvider._topSitesWithSuggestedLinks], ["hrblock.com", "1040.com"]);
+        do_check_eq(link.frecency, SUGGESTED_FRECENCY);
+        do_check_eq(link.type, "affiliate");
+        do_check_eq(link.url, suggestedTile.url);
+        let timeDelta = Date.now() - testStartTime;
+        if (!deleteFlag) {
+          // this is start timeout corresponding to campaign start
+          // a seconds must pass and targetedSite must be set
+          do_check_true(timeDelta >= 1000);
+          do_check_eq(link.targetedSite, "hrblock.com");
+          do_check_true(DirectoryLinksProvider._campaignTimeoutID);
+        }
+        else {
+          // this is the campaign end timeout, so 3 seconds must pass
+          // and timeout should be cleared
+          do_check_true(timeDelta >= 3000);
+          do_check_false(link.targetedSite);
+          do_check_false(DirectoryLinksProvider._campaignTimeoutID);
+          resolve();
+        }
+      };
+    });
+  }
+
+  // _updateSuggestedTile() is called when fetching directory links.
+  yield testObserver.promise;
+  DirectoryLinksProvider.removeObserver(testObserver);
+
+  // shoudl suggest nothing
+  do_check_eq(DirectoryLinksProvider._updateSuggestedTile(), undefined);
+
+  // set links back to contain directory tile only
+  links.shift();
+
+  // drop the end time - we should pick up the tile
+  suggestedTile.time_limits.end = null;
+  data = {"suggested": [suggestedTile], "directory": [someOtherSite]};
+  dataURI = 'data:application/json,' + JSON.stringify(data);
+
+  // redownload json and getLinks to force time recomputation
+  yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, dataURI);
+
+  // ensure that there's a link returned by _updateSuggestedTile and no timeout
+  let deferred = Promise.defer();
+  DirectoryLinksProvider.getLinks(() => {
+    let link = DirectoryLinksProvider._updateSuggestedTile();
+    // we should have a suggested tile and no timeout
+    do_check_eq(link.type, "affiliate");
+    do_check_eq(link.url, suggestedTile.url);
+    do_check_false(DirectoryLinksProvider._campaignTimeoutID);
+    deferred.resolve();
+  });
+  yield deferred.promise;
+
+  // repeat the test for end time only
+  suggestedTile.time_limits.start = null;
+  suggestedTile.time_limits.end = (new Date(Date.now() + 3000)).toISOString();
+
+  data = {"suggested": [suggestedTile], "directory": [someOtherSite]};
+  dataURI = 'data:application/json,' + JSON.stringify(data);
+
+  // redownload json and call getLinks() to force time recomputation
+  yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, dataURI);
+
+  // ensure that there's a link returned by _updateSuggestedTile and timeout set
+  deferred = Promise.defer();
+  DirectoryLinksProvider.getLinks(() => {
+    let link = DirectoryLinksProvider._updateSuggestedTile();
+    // we should have a suggested tile and timeout set
+    do_check_eq(link.type, "affiliate");
+    do_check_eq(link.url, suggestedTile.url);
+    do_check_true(DirectoryLinksProvider._campaignTimeoutID);
+    DirectoryLinksProvider._clearCampaignTimeout();
+    deferred.resolve();
+  });
+  yield deferred.promise;
+
+  // Cleanup
+  yield promiseCleanDirectoryLinksProvider();
+  DirectoryLinksProvider.getFrecentSitesName = origGetFrecentSitesName;
+  NewTabUtils.isTopPlacesSite = origIsTopPlacesSite;
+  NewTabUtils.getProviderLinks = origGetProviderLinks;
+  DirectoryLinksProvider._getCurrentTopSiteCount = origCurrentTopSiteCount;
+});
+
+add_task(function test_setupStartEndTime() {
+  let currentTime = Date.now();
+  let dt = new Date(currentTime);
+  let link = {
+    time_limits: {
+      start: dt.toISOString()
+    }
+  };
+
+  // test ISO translation
+  DirectoryLinksProvider._setupStartEndTime(link);
+  do_check_eq(link.startTime, currentTime);
+
+  // test localtime translation
+  let shiftedDate = new Date(currentTime - dt.getTimezoneOffset()*60*1000);
+  link.time_limits.start = shiftedDate.toISOString().replace(/Z$/, "");
+
+  DirectoryLinksProvider._setupStartEndTime(link);
+  do_check_eq(link.startTime, currentTime);
+
+  // throw some garbage into date string
+  delete link.startTime;
+  link.time_limits.start = "no date"
+  DirectoryLinksProvider._setupStartEndTime(link);
+  do_check_false(link.startTime);
+
+  link.time_limits.start = "2015-99999-01T00:00:00"
+  DirectoryLinksProvider._setupStartEndTime(link);
+  do_check_false(link.startTime);
+
+  link.time_limits.start = "20150501T00:00:00"
+  DirectoryLinksProvider._setupStartEndTime(link);
+  do_check_false(link.startTime);
+});