diff --git a/lib/static_resources.js b/lib/static_resources.js index 15fce2a8b3782857152b448697115ef8c3d19931..92f5c95cefa19b66e9206ed015cf9f95de18d371 100644 --- a/lib/static_resources.js +++ b/lib/static_resources.js @@ -80,6 +80,9 @@ var dialog_js = und.flatten([ '/shared/history.js', '/shared/state_machine.js', + '/shared/models/models.js', + '/shared/models/interaction_data.js', + '/shared/modules/interaction_data.js', '/dialog/resources/internal_api.js', diff --git a/resources/static/shared/models/interaction_data.js b/resources/static/shared/models/interaction_data.js new file mode 100644 index 0000000000000000000000000000000000000000..c1bfe743328bc66f13c136e9aa401bf7c56fcc70 --- /dev/null +++ b/resources/static/shared/models/interaction_data.js @@ -0,0 +1,164 @@ +/*globals BrowserID: true */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +BrowserID.Models.InteractionData = (function() { + "use strict"; + + var bid = BrowserID, + storage = bid.getStorage(), + network = bid.Network, + complete = bid.Helpers.complete; + + function getInteractionData() { + var interactionData; + try { + interactionData = JSON.parse(storage.interaction_data); + } catch(e) { + } + + return interactionData || {}; + } + + function setInteractionData(data) { + try { + storage.interaction_data = JSON.stringify(data); + } catch(e) { + console.log(e); + storage.removeItem("interaction_data"); + } + } + + function push(newData) { + stageCurrent(); + + var interactionData = getInteractionData(); + interactionData.current = newData; + + setInteractionData(interactionData); + } + + function getCurrent() { + var interactionData = getInteractionData(); + + return interactionData.current; + } + + function setCurrent(data) { + var interactionData = getInteractionData(); + interactionData.current = data; + setInteractionData(interactionData); + } + + function stageCurrent() { + // Push existing current data to the staged list. This allows + // us to get/clear the staged list without affecting the current data. + var interactionData = getInteractionData(); + + if (interactionData.current) { + var staged = interactionData.staged = interactionData.staged || []; + staged.unshift(interactionData.current); + + delete interactionData.current; + + setInteractionData(interactionData); + } + } + + function getStaged() { + var interactionData = getInteractionData(); + return interactionData.staged || []; + } + + function clearStaged() { + var interactionData = getInteractionData(); + delete interactionData.staged; + setInteractionData(interactionData); + } + + // We'll try to publish past interaction data to the server if it exists. + // The psuedo transactional model employed here is to attempt to post, and + // only once we receive a server response do we purge data. We don't + // care if the post is a success or failure as this data is not + // critical to the functioning of the system (and some failure scenarios + // simply won't resolve with retries - like corrupt data, or too much + // data) + function publishStaged(oncomplete) { + var data = getStaged(); + + // XXX: should we even try to post data if it's larger than some reasonable + // threshold? + if (data && data.length !== 0) { + network.sendInteractionData(data, function() { + clearStaged(); + complete(oncomplete, true); + }, function(status) { + // if the server returns a 413 error, (too much data posted), then + // let's clear our local storage and move on. This does mean we + // loose some interaction data, but it shouldn't be statistically + // significant. + if (status && status.network && status.network.status === 413) { + clearStaged(); + } + complete(oncomplete, false); + }); + } + else { + complete(oncomplete, false); + } + } + + return { + /** + * add a new interaction blob to localstorage, this will *push* any stored + * blobs to the 'staged' backlog, and happens when a new dialog interaction + * begins. + * @method push + * @param {object} data - an object to push onto the queue + * @returns nada + */ + push: push, + /** + * read the interaction data blob associated with the current interaction + * @method getCurrent + * @returns a JSON object containing the latest interaction data blob + */ + getCurrent: getCurrent, + /** + * overwrite the interaction data blob associated with the current interaction + * @method setCurrent + * @param {object} data - the object to overwrite current with + */ + setCurrent: setCurrent, + /** + * Shift any "current" data into the staged list. No data will be listed + * as current afterwards. + * @method stageCurrent + */ + stageCurrent: stageCurrent, + /** + * get all past saved interaction data (returned as a JSON array), excluding + * the "current" data (that which is being collected now). + * @method getStaged + * @returns an array, possibly of length zero if no past interaction data is + * available + */ + getStaged: getStaged, + /** + * publish staged data. Staged data will be cleared if successfully posted + * to server or if server returns 413 - too much data. + * @param {function} [oncomplete] - function to call when complete. Called + * with true if data was successfully sent to server, false otw. + * @method publishStaged + */ + publishStaged: publishStaged, + /** + * clear all interaction data, except the current, in-progress + * collection. + * @method clearStaged() + */ + clearStaged: clearStaged + }; + +}()); diff --git a/resources/static/shared/models/models.js b/resources/static/shared/models/models.js new file mode 100644 index 0000000000000000000000000000000000000000..845cc04763349d29296935b5148f22f7f37f5032 --- /dev/null +++ b/resources/static/shared/models/models.js @@ -0,0 +1,7 @@ +/*globals BrowserID: true */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +BrowserID.Models = {}; + diff --git a/resources/static/shared/modules/interaction_data.js b/resources/static/shared/modules/interaction_data.js index 97b981556365a017b70ee369142f1a68a690c73c..0ef57fd67381c5180d8e61a0734124032d6bf1bd 100644 --- a/resources/static/shared/modules/interaction_data.js +++ b/resources/static/shared/modules/interaction_data.js @@ -24,14 +24,13 @@ BrowserID.Modules.InteractionData = (function() { var bid = BrowserID, - storage = bid.Storage.interactionData, + model = bid.Models.InteractionData, network = bid.Network, complete = bid.Helpers.complete, dom = bid.DOM, sc; function onSessionContext(msg, result) { - console.log("session context"); var self=this; // defend against onSessionContext being called multiple times @@ -43,106 +42,101 @@ BrowserID.Modules.InteractionData = (function() { // dialog session is allowed to sample data. This is because the original // dialog session has already decided whether to collect data. - // Continuation after publishing MUST be done - publishStored(function() { - // set the sample rate as defined by the server. It's a value - // between 0..1, integer or float, and it specifies the percentage - // of the time that we should capture - var sampleRate = result.data_sample_rate || 0; - - if (typeof self.samplingEnabled === "undefined") { - // now that we've got sample rate, let's smash it into a boolean - // probalistically - self.samplingEnabled = Math.random() <= sampleRate; - } + model.stageCurrent(); + publishStored.call(self); - // if we're not going to sample, kick out early. - if (!self.samplingEnabled) { - return; - } + // set the sample rate as defined by the server. It's a value + // between 0..1, integer or float, and it specifies the percentage + // of the time that we should capture + var sampleRate = result.data_sample_rate || 0; - var currentData = { - event_stream: self.initialEventStream, - sample_rate: sampleRate, - timestamp: result.server_time, - local_timestamp: self.startTime.toString(), - lang: dom.getAttr('html', 'lang') || null, - }; + if (typeof self.samplingEnabled === "undefined") { + // now that we've got sample rate, let's smash it into a boolean + // probalistically + self.samplingEnabled = Math.random() <= sampleRate; + } - if (window.screen) { - currentData.screen_size = { - width: window.screen.width, - height: window.screen.height - }; - } + // if we're not going to sample, kick out early. + if (!self.samplingEnabled) { + return; + } - // cool. now let's persist the initial data. This data will be published - // as soon as the first session_context completes for the next dialog - // session. Use a push because old data *may not* have been correctly - // published to a down server or erroring web service. - console.log("pushing currentData"); - storage.push(currentData); + var currentData = { + event_stream: self.initialEventStream, + sample_rate: sampleRate, + timestamp: result.server_time, + local_timestamp: self.startTime.toString(), + lang: dom.getAttr('html', 'lang') || null, + }; + + if (window.screen) { + currentData.screen_size = { + width: window.screen.width, + height: window.screen.height + }; + } - self.initialEventStream = null; + // cool. now let's persist the initial data. This data will be published + // as soon as the first session_context completes for the next dialog + // session. Use a push because old data *may not* have been correctly + // published to a down server or erroring web service. + model.push(currentData); - self.samplesBeingStored = true; - }); + self.initialEventStream = null; + + self.samplesBeingStored = true; } - // At every load, after session_context returns, we'll try to publish - // past interaction data to the server if it exists. The psuedo - // transactional model employed here is to attempt to post, and only - // once we receive a server response do we purge data. We don't - // care if the post is a success or failure as this data is not - // critical to the functioning of the system (and some failure scenarios - // simply won't resolve with retries - like corrupt data, or too much - // data) + // At every load, after session_context returns, try to publish the previous + // data. We have to wait until session_context completes so that we have + // a csrf token to send. function publishStored(oncomplete) { - var data = storage.get(); - - // XXX: should we even try to post data if it's larger than some reasonable - // threshold? - console.log(data); - if (data && data.length !== 0) { - network.sendInteractionData(data, function() { - console.log("clear"); - storage.clear(); - complete(oncomplete, true); - }, function(status) { - // if the server returns a 413 error, (too much data posted), then - // let's clear our local storage and move on. This does mean we - // loose some interaction data, but it shouldn't be statistically - // significant. - if (status && status.network && status.network.status === 413) { - storage.clear(); - } - complete(oncomplete, false); - }); - } - else { - complete(oncomplete, false); - } + var self=this; + + model.publishStaged(function(status) { + var msg = status ? "interaction_data_send_complete" : "interaction_data_send_error"; + self.publish(msg); + complete(oncomplete, status); + }); } function addEvent(eventName) { var self=this; - if (self.samplingEnabled === false) return; var eventData = [ eventName, new Date() - self.startTime ]; if (self.samplesBeingStored) { - console.log("add stored event:" + eventName); - var d = storage.current() || {}; + var d = model.getCurrent() || {}; if (!d.event_stream) d.event_stream = []; d.event_stream.push(eventData); - storage.setCurrent(d); + model.setCurrent(d); } else { - console.log("add initial event:" + eventName); self.initialEventStream.push(eventData); } } + function getCurrent() { + var self=this; + if(self.samplingEnabled === false) return; + + if (self.samplesBeingStored) { + return model.getCurrent(); + } + } + + function getCurrentEventStream() { + var self=this; + if(self.samplingEnabled === false) return; + + if (self.samplesBeingStored) { + return model.getCurrent().event_stream; + } + else { + return self.initialEventStream; + } + } + var Module = bid.Modules.PageModule.extend({ start: function(options) { options = options || {}; @@ -155,29 +149,28 @@ BrowserID.Modules.InteractionData = (function() { // a continuation, samplingEnabled will be decided on the first " // context_info" event, which corresponds to the first time // 'session_context' returns from the server. + // samplingEnabled flag ignored for a continuation. self.samplingEnabled = options.samplingEnabled; // continuation means the users dialog session is continuing, probably // due to a redirect to an IdP and then a return after authentication. if (options.continuation) { - var previousData = storage.current(); - - var samplingEnabled = self.samplingEnabled = !!previousData.event_stream; - if (samplingEnabled) { + // There will be no current data if the previous session was not + // allowed to save. + var previousData = model.getCurrent(); + if (previousData) { self.startTime = Date.parse(previousData.local_timestamp); - if (typeof self.samplingEnabled === "undefined") { - self.samplingEnabled = samplingEnabled; - } // instead of waiting for session_context to start appending data to // localStorage, start saving into localStorage now. - self.samplesBeingStored = true; + self.samplingEnabled = self.samplesBeingStored = true; } else { - // If there was no previous event stream, that means data collection + // If there was no previous data, that means data collection // was not allowed for the previous session. Return with no further // action, data collection is not allowed for this session either. + self.samplingEnabled = false; return; } } @@ -187,7 +180,7 @@ BrowserID.Modules.InteractionData = (function() { // The initialEventStream is used to store events until onSessionContext // is called. Once onSessionContext is called and it is known whether // the user's data will be saved, initialEventStream will either be - // discarded or added to the data set that is saved to localStorage. + // discarded or added to the data set that is saved to localmodel. self.initialEventStream = []; self.samplesBeingStored = false; @@ -202,16 +195,8 @@ BrowserID.Modules.InteractionData = (function() { }, addEvent: addEvent, - - getCurrentStoredData: function() { - var und; - return this.samplesBeingStored ? storage.current() : und; - }, - - getEventStream: function() { - return this.samplesBeingStored ? storage.current().event_stream : this.initialEventStream || []; - }, - + getCurrent: getCurrent, + getCurrentEventStream: getCurrentEventStream, publishStored: publishStored }); diff --git a/resources/static/shared/storage.js b/resources/static/shared/storage.js index 82ed0be6d3838d2d4e93695530005162997e38e6..1fa52161a9da6e0b65c7c0bad02e79dfe9682800 100644 --- a/resources/static/shared/storage.js +++ b/resources/static/shared/storage.js @@ -3,13 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /*globals BrowserID: true, console: true */ - -BrowserID.Storage = (function() { - "use strict"; - - var jwcrypto, - ONE_DAY_IN_MS = (1000 * 60 * 60 * 24), - storage; +BrowserID.getStorage = function() { + var storage; try { storage = localStorage; @@ -27,6 +22,16 @@ BrowserID.Storage = (function() { }; } + return storage; +}; + +BrowserID.Storage = (function() { + "use strict"; + + var jwcrypto, + ONE_DAY_IN_MS = (1000 * 60 * 60 * 24), + storage = BrowserID.getStorage(); + // temporary, replace with helpers.log if storage uses elog long term... function elog (msg) { if (window.console && console.error) console.error(msg); @@ -401,56 +406,6 @@ BrowserID.Storage = (function() { storage.emailToUserID = JSON.stringify(allInfo); } - function pushInteractionData(data) { - var id; - try { - id = JSON.parse(storage.interactionData); - id.unshift(data); - } catch(e) { - id = [ data ]; - } - storage.interactionData = JSON.stringify(id); - } - - function currentInteractionData() { - try { - return storage.interactionData ? JSON.parse(storage.interactionData)[0] : {}; - } catch(e) { - elog(e); - return {}; - } - } - - function setCurrentInteractionData(data) { - var id; - try { - id = JSON.parse(storage.interactionData); - id[0] = data; - } catch(e) { - elog(e); - id = [ data ]; - } - storage.interactionData = JSON.stringify(id); - } - - function getAllInteractionData() { - try { - return storage.interactionData ? JSON.parse(storage.interactionData) : []; - } catch(e) { - if (window.console && console.error) console.error(e); - return []; - } - } - - function clearInteractionData() { - try { - storage.interactionData = JSON.stringify([]); - } catch(e) { - storage.removeItem("interactionData"); - elog(e); - } - } - return { /** * Add an email address and optional key pair. @@ -533,44 +488,6 @@ BrowserID.Storage = (function() { remove: generic2KeyRemove.curry("main_site", "signInEmail") }, - interactionData: { - /** - * add a new interaction blob to localstorage, this will *push* any stored - * blobs to the 'completed' backlog, and happens when a new dialog interaction - * begins. - * @param {object} data - an object to push onto the queue - * @method interactionData.push() - * @returns nada - */ - push: pushInteractionData, - /** - * read the interaction data blob associated with the current interaction - * @method interactionData.current() - * @returns a JSON object containing the latest interaction data blob - */ - current: currentInteractionData, - /** - * overwrite the interaction data blob associated with the current interaction - * @param {object} data - the object to overwrite current with - * @method interactionData.setCurrent() - */ - setCurrent: setCurrentInteractionData, - /** - * get all past saved interaction data (returned as a JSON array), excluding - * the "current" data (that which is being collected now). - * @method interactionData.get() - * @returns an array, possibly of length zero if no past interaction data is - * available - */ - get: getAllInteractionData, - /** - * clear all interaction data, except the current, in-progress - * collection. - * @method interactionData.clear() - */ - clear: clearInteractionData - }, - usersComputer: { /** * Query whether the user has confirmed that this is their computer diff --git a/resources/static/test/cases/shared/models/interaction_data.js b/resources/static/test/cases/shared/models/interaction_data.js new file mode 100644 index 0000000000000000000000000000000000000000..5454ccfc6e88f1e0a8cd1e250e132521d50192ff --- /dev/null +++ b/resources/static/test/cases/shared/models/interaction_data.js @@ -0,0 +1,108 @@ + +/*jshint browsers:true, forin: true, laxbreak: true */ +/*global test: true, start: true, stop: true, module: true, ok: true, equal: true, BrowserID:true */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +(function() { + var bid = BrowserID, + model = bid.Models.InteractionData, + testHelpers = bid.TestHelpers, + testObjectValuesEqual = testHelpers.testObjectValuesEqual, + xhr = bid.Mocks.xhr; + + module("shared/models/interaction_data", { + setup: function() { + testHelpers.setup(); + localStorage.removeItem("interaction_data"); + }, + + teardown: function() { + testHelpers.teardown(); + } + }); + + test("after push, most recently pushed data available through getCurrent, getStaged gets previous data sets", function() { + model.push({ foo: "bar" }); + equal(model.getCurrent().foo, "bar", + "after pushing new interaction data, it's returned from .getCurrent()"); + + equal(model.getStaged().length, 0, "no data is yet staged"); + + model.push({ foo: "baz" }); + + equal(model.getCurrent().foo, "baz", "current points to new data set") + var staged = model.getStaged(); + + equal(staged.length, 1, "only one staged item"); + testObjectValuesEqual(staged[0], { foo: "bar" }); + }); + + test("setCurrent data overwrites current", function() { + model.clearStaged(); + model.push({ foo: "bar" }); + model.setCurrent({ foo: "baz" }); + equal(model.getCurrent().foo, "baz", + "overwriting current interaction data works"); + }); + + test("clearStaged clears staged interaction data but leaves current data unaffected", function() { + model.push({ foo: "bar" }); + model.push({ foo: "baz" }); + model.clearStaged(); + equal(model.getStaged().length, 0, + "after clearStageding, interaction data is zero length"); + equal(model.getCurrent().foo, "baz", + "after clearStageding, current data is unaffected"); + }); + + test("stageCurrent - stage the current data, if any. no data is current afterwards", function() { + // There is no current data to stage. + model.stageCurrent(); + equal(model.getStaged().length, 0, "no data to staged"); + + model.push({ foo: "bar" }); + model.stageCurrent(); + + equal(model.getStaged().length, 1, "current data staged"); + equal(typeof model.getCurrent(), "undefined", "current data removed after being staged"); + }); + + asyncTest("publishStored - publish any staged data", function() { + // There is no currently staged data. + model.publishStaged(function(status) { + equal(status, false, "no data currently staged"); + + // Simulate a throttling + // desired result - data is purged from staging table + + // The first pushed data will become staged. + model.push({ foo: "bar" }); + model.stageCurrent(); + + xhr.useResult("throttle"); + model.publishStaged(function(status) { + equal(false, status, "data throttling returns false status"); + // the previously staged data should we wiped on a throttling response. + + // When the interaction_data next completes, this will be the only data + // that is pushed. + model.push({ foo: "baz" }); + model.stageCurrent(); + + xhr.useResult("valid"); + model.publishStaged(function(status) { + equal(true, status, "data successfully posted"); + var request = xhr.getLastRequest('/wsapi/interaction_data'), + previousSessionsData = JSON.parse(request.data).data; + + equal(previousSessionsData.length, 1, "sending correct result sets"); + equal(previousSessionsData[0].foo, "baz", "correct data sent"); + start(); + }); + }); + + }); + + }); +}()); diff --git a/resources/static/test/cases/shared/modules/interaction_data.js b/resources/static/test/cases/shared/modules/interaction_data.js index 951c506903538a898a6e0c80c8fa8913aeff3593..88610f6a9f3fdc5e08a64b0abf7198c15bfdfab3 100644 --- a/resources/static/test/cases/shared/modules/interaction_data.js +++ b/resources/static/test/cases/shared/modules/interaction_data.js @@ -9,11 +9,16 @@ var bid = BrowserID, testHelpers = bid.TestHelpers, network = bid.Network, - storage = bid.Storage, + model = bid.Models.InteractionData, + xhr = bid.Mocks.xhr, + mediator = bid.Mediator, controller; module("shared/modules/interaction_data", { - setup: testHelpers.setup, + setup: function() { + testHelpers.setup(); + localStorage.removeItem("interaction_data"); + }, teardown: function() { testHelpers.teardown(); @@ -36,25 +41,35 @@ } asyncTest("samplingEnabled - ensure data collection working as expected", function() { + // Desired sequence: + // 1. When session_context completes, initialize this session's interaction + // data, sends previous session's data. + // 2. when network.sendInteractionData completes, previous session's data is + // erased, current session's data is unaffected. + + // simulate data stored for last session + model.push({ timestamp: new Date().getTime() }); + createController(); controller.addEvent("before_session_context"); - var events = controller.getEventStream(); + var events = controller.getCurrentEventStream(); ok(indexOfEvent(events, "before_session_context") > -1, "before_session_context correctly saved to event stream"); - ok(indexOfEvent(events, "after_session_context") === -1, "after_session_context not yet added to current event stream"); - // with context initializes the current stored data. - network.withContext(function() { - var data = controller.getCurrentStoredData(); + // Add an XHR delay to simulate interaction_data completeing after + // session_context completes. + xhr.setDelay(5); + + mediator.subscribe("interaction_data_send_complete", function() { + var data = controller.getCurrent(); // Make sure expected items are in the current stored data. testHelpers.testKeysInObject(data, ["event_stream", "sample_rate", "timestamp", "lang"]); controller.addEvent("after_session_context"); - var events = controller.getEventStream(); - + events = controller.getCurrentEventStream(); // Make sure both the before_session_context and after_session_context // are both on the event stream. ok(indexOfEvent(events, "before_session_context") > -1, "before_session_context correctly saved to current event stream"); @@ -73,28 +88,7 @@ start(); }); - }); - - asyncTest("publish data", function() { - createController(); - - // force saved data to be cleared. - storage.interactionData.clear(); - controller.publishStored(function(status) { - equal(status, false, "no data to publish"); - - // session context is required start saving events to localStorage. - network.withContext(function() { - - // Add an event which should allow us to publish - controller.addEvent("something_special"); - controller.publishStored(function(status) { - equal(status, true, "data correctly published"); - - start(); - }); - }); - }); + network.withContext(); }); asyncTest("samplingEnabled set to false - no data collection occurs", function() { @@ -104,12 +98,9 @@ // no stored data. network.withContext(function() { controller.addEvent("after_session_context"); - var events = controller.getEventStream(); - - var index = indexOfEvent(events, "after_session_context"); - equal(index, -1, "events not being stored"); - equal(typeof controller.getCurrentStoredData(), "undefined", "no stored data"); + equal(typeof controller.getCurrent(), "undefined", "no stored data"); + equal(typeof controller.getCurrentEventStream(), "undefined", "no data stored"); controller.publishStored(function(status) { equal(status, false, "there was no data to publish"); @@ -135,7 +126,7 @@ network.withContext(function() { controller.addEvent("session2_after_session_context"); - var events = controller.getEventStream(); + var events = controller.getCurrentEventStream(); ok(indexOfEvent(events, "session1_before_session_context") > -1, "session1_before_session_context correctly saved to current event stream"); ok(indexOfEvent(events, "session1_after_session_context") > -1, "session1_after_session_context correctly saved to current event stream"); @@ -166,12 +157,8 @@ network.withContext(function() { controller.addEvent("session2_after_session_context"); - var events = controller.getEventStream(); - - ok(indexOfEvent(events, "session1_before_session_context") === -1, "no data collected"); - ok(indexOfEvent(events, "session1_after_session_context") === -1, "no data collected"); - ok(indexOfEvent(events, "session2_before_session_context") === -1, "no data collected"); - ok(indexOfEvent(events, "session2_after_session_context") === -1, "no data collected"); + equal(typeof controller.getCurrent(), "undefined", "no data collected"); + equal(typeof controller.getCurrentEventStream(), "undefined", "no data collected"); controller.publishStored(function(status) { equal(status, false, "there was no data to publish"); @@ -179,8 +166,35 @@ }); }); }); - }); + asyncTest("simulate failed starts - data not sent until second successful session_context", function() { + // simulate 3 dialog openings without session completing for any of them. + // On the forth attempt at opening the dialog, the data is successfully + // sent. + + createController(); + controller.addEvent("session1_before_session_context"); + + // simulate this as the first successful connection to backend to find out + // whether data collection is allowed. + createController(); + controller.addEvent("session2_before_session_context"); + network.withContext(function() { + + createController(); + controller.addEvent("session2_before_session_context"); + + // Data is sent on the second successful call to session_context + network.withContext(function() { + var request = xhr.getLastRequest('/wsapi/interaction_data'), + previousSessionsData = JSON.parse(request.data).data; + + equal(previousSessionsData.length, 1, "sending correct result sets"); + start(); + }); + }); + }); + }()); diff --git a/resources/static/test/cases/shared/storage.js b/resources/static/test/cases/shared/storage.js index b9b230b5e362aacd4174960d173810eabea5faa7..c9406c84a21ef077b4a150f956a3666a0eccc505 100644 --- a/resources/static/test/cases/shared/storage.js +++ b/resources/static/test/cases/shared/storage.js @@ -179,37 +179,5 @@ equal(typeof storage.signInEmail.get(), "undefined", "after remove, signInEmail is empty"); }); - test("push interaction data and get current", function() { - storage.interactionData.push({ foo: "bar" }); - equal(storage.interactionData.current().foo, "bar", - "after pushing new interaction data, it's returned from .current()"); - }); - - test("set interaction data overwrites current", function() { - storage.interactionData.clear(); - storage.interactionData.push({ foo: "bar" }); - storage.interactionData.setCurrent({ foo: "baz" }); - equal(storage.interactionData.current().foo, "baz", - "overwriting current interaction data works"); - equal(storage.interactionData.get().length, 1, - "overwriting doesn't append"); - }); - - test("clear interaction data", function() { - storage.interactionData.push({ foo: "bar" }); - storage.interactionData.push({ foo: "bar" }); - storage.interactionData.clear(); - equal(storage.interactionData.get().length, 0, - "after clearing, interaction data is zero length"); - }); - - test("get interaction data returns all data", function() { - storage.interactionData.push({ foo: "old2" }); - storage.interactionData.clear(); - storage.interactionData.push({ foo: "old1" }); - var d = storage.interactionData.get(); - equal(d.length, 1, "get() returns complete unpublished data blobs"); - equal(d[0].foo, 'old1', "get() returns complete unpublished data blobs"); - }); }()); diff --git a/resources/static/test/mocks/xhr.js b/resources/static/test/mocks/xhr.js index 8b505f997d4680903d922e8de6e2a90d78f08cc1..5d0c5a226e92100d896359b187a5a624c245b411 100644 --- a/resources/static/test/mocks/xhr.js +++ b/resources/static/test/mocks/xhr.js @@ -21,13 +21,18 @@ BrowserID.Mocks.xhr = (function() { var random_cert = "eyJhbGciOiJSUzEyOCJ9.eyJpc3MiOiJpc3N1ZXIuY29tIiwiZXhwIjoxMzE2Njk1MzY3NzA3LCJwdWJsaWMta2V5Ijp7ImFsZ29yaXRobSI6IlJTIiwibiI6IjU2MDYzMDI4MDcwNDMyOTgyMzIyMDg3NDE4MTc2ODc2NzQ4MDcyMDM1NDgyODk4MzM0ODExMzY4NDA4NTI1NTk2MTk4MjUyNTE5MjY3MTA4MTMyNjA0MTk4MDA0NzkyODQ5MDc3ODY4OTUxOTA2MTcwODEyNTQwNzEzOTgyOTU0NjUzODEwNTM5OTQ5Mzg0NzEyNzczMzkwMjAwNzkxOTQ5NTY1OTAzNDM5NTIxNDI0OTA5NTc2ODMyNDE4ODkwODE5MjA0MzU0NzI5MjE3MjA3MzYwMTA1OTA2MDM5MDIzMjk5NTYxMzc0MDk4OTQyNzg5OTk2NzgwMTAyMDczMDcxNzYwODUyODQxMDY4OTg5ODYwNDAzNDMxNzM3NDgwMTgyNzI1ODUzODk5NzMzNzA2MDY5IiwiZSI6IjY1NTM3In0sInByaW5jaXBhbCI6eyJlbWFpbCI6InRlc3R1c2VyQHRlc3R1c2VyLmNvbSJ9fQ.aVIO470S_DkcaddQgFUXciGwq2F_MTdYOJtVnEYShni7I6mqBwK3fkdWShPEgLFWUSlVUtcy61FkDnq2G-6ikSx1fUZY7iBeSCOKYlh6Kj9v43JX-uhctRSB2pI17g09EUtvmb845EHUJuoowdBLmLa4DSTdZE-h4xUQ9MsY7Ik"; /** - * This is the results table, the keys are the request type, url, and + * This is the responses table, the keys are the request type, url, and * a "selector" for testing. The right is the expected return value, already * decoded. If a result is "undefined", the request's error handler will be * called. */ var xhr = { - results: { + // Keep track of the last request made to each wsapi call. keyed only on + // url - for instince - instead of "get /wsapi/session_context + // valid", the key would only be "/wsapi/session_context" + requests: {}, + + responses: { "get /wsapi/session_context valid": contextInfo, // We are going to test for XHR failures for session_context using // the flag contextAjaxError. @@ -85,7 +90,6 @@ BrowserID.Mocks.xhr = (function() { "post /wsapi/stage_email invalid": { success: false }, "post /wsapi/stage_email throttle": 429, "post /wsapi/stage_email ajaxError": undefined, - "post /wsapi/cert_key ajaxError": undefined, "get /wsapi/email_addition_status?email=testuser%40testuser.com complete": { status: "complete" }, "get /wsapi/email_addition_status?email=registered%40testuser.com pending": { status: "pending" }, "get /wsapi/email_addition_status?email=registered%40testuser.com complete": { status: "complete" }, @@ -124,6 +128,7 @@ BrowserID.Mocks.xhr = (function() { "post /wsapi/prolong_session unauthenticated": 400, "post /wsapi/prolong_session ajaxError": undefined, "post /wsapi/interaction_data valid": { success: true }, + "post /wsapi/interaction_data throttle": 413, "post /wsapi/interaction_data ajaxError": undefined }, @@ -136,61 +141,63 @@ BrowserID.Mocks.xhr = (function() { }, useResult: function(result) { - xhr.resultType = result; + xhr.responseName = result; }, - getLastRequest: function() { - return this.req; + getLastRequest: function(key) { + var req = this.request; + if (key) { + req = this.requests[key]; + } + + return req; }, - ajax: function(obj) { + ajax: function(request) { //console.log("ajax request"); - var type = obj.type ? obj.type.toLowerCase() : "get"; - - var req = this.req = { - type: type, - url: obj.url, - data: obj.data - }; + var type = request.type ? request.type.toLowerCase() : "get"; + this.request = request = _.extend(request, { + type: type + }); - if(type === "post" && obj.data.indexOf("csrf") === -1) { + if (type === "post" && request.data.indexOf("csrf") === -1) { ok(false, "missing csrf token on POST request"); } - - var resultType = xhr.resultType; + var responseName = xhr.responseName; // Unless the contextAjaxError is specified, use the "valid" context info. // This makes it so we do not have to keep adding new items for // context_info for every possible result type. - if(req.url === "/wsapi/session_context" && resultType !== "contextAjaxError") { - resultType = "valid"; + if (request.url === "/wsapi/session_context" && responseName !== "contextAjaxError") { + responseName = "valid"; } - var resName = req.type + " " + req.url + " " + resultType; + var responseKey = request.type + " " + request.url + " " + responseName, + response = xhr.responses[responseKey], + typeofResponse = typeof response; - var result = xhr.results[resName]; + this.requests[request.url] = request; - var type = typeof result; - if(type === "function") { - result(obj.success); + if (typeofResponse === "function") { + response(request.success); } - else if(!(type == "number" || type == "undefined")) { - if(obj.success) { - if(delay) { + else if (!(typeofResponse == "number" || typeofResponse == "undefined")) { + if (request.success) { + if (delay) { // simulate response delay - _.delay(obj.success, delay, result); + _.delay(request.success, delay, response); } else { - obj.success(result); + request.success(response); } } } - else if (obj.error) { - // Invalid result - either invalid URL, invalid GET/POST or - // invalid resultType - obj.error({ status: result || 400, responseText: "response text" }, "errorStatus", "errorThrown"); + else if (request.error) { + // Invalid response - either invalid URL, invalid GET/POST or + // invalid responseName + request.error({ status: response || 400, responseText: "response text" }, "errorStatus", "errorThrown"); } } }; diff --git a/resources/static/test/testHelpers/helpers.js b/resources/static/test/testHelpers/helpers.js index 287ba8393c039d0519f9c892fcc79495f62d051f..5f72fae9ff39fc56b9b95de99c2b142962d89816 100644 --- a/resources/static/test/testHelpers/helpers.js +++ b/resources/static/test/testHelpers/helpers.js @@ -181,7 +181,7 @@ BrowserID.TestHelpers = (function() { start(); }); - if(transport.resultType === "valid") { + if(transport.responseName === "valid") { transport.useResult("ajaxError"); } @@ -200,6 +200,11 @@ BrowserID.TestHelpers = (function() { }, testKeysInObject: function(objToTest, expected, msg) { + if (!objToTest) { + ok(false, "Missing object to test against"); + return; + } + for(var i=0, key; key=expected[i]; ++i) { ok(key in objToTest, msg || ("object contains " + key)); } diff --git a/resources/views/test.ejs b/resources/views/test.ejs index 30c4faf532c2d3c034eb70b9fe8a96402c0a3e40..5b1890c5630a9ea5cb134fe3c722dbf4c1e44fff 100644 --- a/resources/views/test.ejs +++ b/resources/views/test.ejs @@ -104,6 +104,9 @@ <script src="/shared/history.js"></script> <script src="/shared/state_machine.js"></script> + <script src="/shared/models/models.js"></script> + <script src="/shared/models/interaction_data.js"></script> + <script src="/shared/modules/page_module.js"></script> <script src="/shared/modules/xhr_delay.js"></script> <script src="/shared/modules/xhr_disable_form.js"></script> @@ -155,6 +158,8 @@ <script src="cases/shared/history.js"></script> <script src="cases/shared/state_machine.js"></script> + <script src="cases/shared/models/interaction_data.js"></script> + <script src="cases/shared/modules/page_module.js"></script> <script src="cases/shared/modules/xhr_delay.js"></script> <script src="cases/shared/modules/xhr_disable_form.js"></script>