Bug 1088672 - Part 3. Rewrite Loop's incoming call handling in the flux style. Get the accept and cancel buttons working again on the accept call view. r=mikedeboer
authorMark Banner <standard8@mozilla.com>
Thu, 12 Mar 2015 14:01:38 +0000
changeset 233267 dc2dded44d2202b864fdab2babc05607c81fd209
parent 233266 8119c5b062538430dafae8a289b418294225668d
child 233268 132acc4464e6ca66a36ffc3c8dadd28dad94d987
push id28409
push userryanvm@gmail.com
push dateThu, 12 Mar 2015 21:55:43 +0000
treeherdermozilla-central@849053cb635d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer
bugs1088672
milestone39.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 1088672 - Part 3. Rewrite Loop's incoming call handling in the flux style. Get the accept and cancel buttons working again on the accept call view. r=mikedeboer
browser/components/loop/content/js/conversationViews.js
browser/components/loop/content/js/conversationViews.jsx
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/conversationStore.js
browser/components/loop/test/desktop-local/conversationViews_test.js
browser/components/loop/test/shared/conversationStore_test.js
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -136,17 +136,17 @@ loop.conversationViews = (function(mozL1
       );
     }
   });
 
   // Matches strings of the form "<nonspaces>@<nonspaces>" or "+<digits>"
   var EMAIL_OR_PHONE_RE = /^(:?\S+@\S+|\+\d+)$/;
 
   var AcceptCallView = React.createClass({displayName: "AcceptCallView",
-    mixins: [sharedMixins.DropdownMenuMixin, sharedMixins.AudioMixin],
+    mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
       callType: React.PropTypes.string.isRequired,
       callerId: React.PropTypes.string.isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       // Only for use by the ui-showcase
       showMenu: React.PropTypes.bool
     },
@@ -161,54 +161,60 @@ loop.conversationViews = (function(mozL1
       var target = e.target;
       if (!target.classList.contains('btn-chevron')) {
         this._hideDeclineMenu();
       }
     },
 
     _handleAccept: function(callType) {
       return function() {
-        this.props.model.set("selectedCallType", callType);
-        this.props.model.trigger("accept");
+        this.props.dispatcher.dispatch(new sharedActions.AcceptCall({
+          callType: callType
+        }));
       }.bind(this);
     },
 
     _handleDecline: function() {
-      this.props.model.trigger("decline");
+      this.props.dispatcher.dispatch(new sharedActions.DeclineCall({
+        blockCaller: false
+      }));
     },
 
     _handleDeclineBlock: function(e) {
-      this.props.model.trigger("declineAndBlock");
+      this.props.dispatcher.dispatch(new sharedActions.DeclineCall({
+        blockCaller: true
+      }));
+
       /* Prevent event propagation
        * stop the click from reaching parent element */
       return false;
     },
 
     /*
      * Generate props for <AcceptCallButton> component based on
      * incoming call type. An incoming video call will render a video
      * answer button primarily, an audio call will flip them.
      **/
     _answerModeProps: function() {
       var videoButton = {
-        handler: this._handleAccept("audio-video"),
+        handler: this._handleAccept(CALL_TYPES.AUDIO_VIDEO),
         className: "fx-embedded-btn-icon-video",
         tooltip: "incoming_call_accept_audio_video_tooltip"
       };
       var audioButton = {
-        handler: this._handleAccept("audio"),
+        handler: this._handleAccept(CALL_TYPES.AUDIO_ONLY),
         className: "fx-embedded-btn-audio-small",
         tooltip: "incoming_call_accept_audio_only_tooltip"
       };
       var props = {};
       props.primary = videoButton;
       props.secondary = audioButton;
 
       // When video is not enabled on this call, we swap the buttons around.
-      if (!this.props.video) {
+      if (this.props.callType === CALL_TYPES.AUDIO_ONLY) {
         audioButton.className = "fx-embedded-btn-icon-audio";
         videoButton.className = "fx-embedded-btn-video-small";
         props.primary = audioButton;
         props.secondary = videoButton;
       }
 
       return props;
     },
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -136,17 +136,17 @@ loop.conversationViews = (function(mozL1
       );
     }
   });
 
   // Matches strings of the form "<nonspaces>@<nonspaces>" or "+<digits>"
   var EMAIL_OR_PHONE_RE = /^(:?\S+@\S+|\+\d+)$/;
 
   var AcceptCallView = React.createClass({
-    mixins: [sharedMixins.DropdownMenuMixin, sharedMixins.AudioMixin],
+    mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
       callType: React.PropTypes.string.isRequired,
       callerId: React.PropTypes.string.isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       // Only for use by the ui-showcase
       showMenu: React.PropTypes.bool
     },
@@ -161,54 +161,60 @@ loop.conversationViews = (function(mozL1
       var target = e.target;
       if (!target.classList.contains('btn-chevron')) {
         this._hideDeclineMenu();
       }
     },
 
     _handleAccept: function(callType) {
       return function() {
-        this.props.model.set("selectedCallType", callType);
-        this.props.model.trigger("accept");
+        this.props.dispatcher.dispatch(new sharedActions.AcceptCall({
+          callType: callType
+        }));
       }.bind(this);
     },
 
     _handleDecline: function() {
-      this.props.model.trigger("decline");
+      this.props.dispatcher.dispatch(new sharedActions.DeclineCall({
+        blockCaller: false
+      }));
     },
 
     _handleDeclineBlock: function(e) {
-      this.props.model.trigger("declineAndBlock");
+      this.props.dispatcher.dispatch(new sharedActions.DeclineCall({
+        blockCaller: true
+      }));
+
       /* Prevent event propagation
        * stop the click from reaching parent element */
       return false;
     },
 
     /*
      * Generate props for <AcceptCallButton> component based on
      * incoming call type. An incoming video call will render a video
      * answer button primarily, an audio call will flip them.
      **/
     _answerModeProps: function() {
       var videoButton = {
-        handler: this._handleAccept("audio-video"),
+        handler: this._handleAccept(CALL_TYPES.AUDIO_VIDEO),
         className: "fx-embedded-btn-icon-video",
         tooltip: "incoming_call_accept_audio_video_tooltip"
       };
       var audioButton = {
-        handler: this._handleAccept("audio"),
+        handler: this._handleAccept(CALL_TYPES.AUDIO_ONLY),
         className: "fx-embedded-btn-audio-small",
         tooltip: "incoming_call_accept_audio_only_tooltip"
       };
       var props = {};
       props.primary = videoButton;
       props.secondary = audioButton;
 
       // When video is not enabled on this call, we swap the buttons around.
-      if (!this.props.video) {
+      if (this.props.callType === CALL_TYPES.AUDIO_ONLY) {
         audioButton.className = "fx-embedded-btn-icon-audio";
         videoButton.className = "fx-embedded-btn-video-small";
         props.primary = audioButton;
         props.secondary = videoButton;
       }
 
       return props;
     },
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -92,16 +92,30 @@ loop.shared.actions = (function() {
 
     /**
      * Used to retry a failed call.
      */
     RetryCall: Action.define("retryCall", {
     }),
 
     /**
+     * Signals when the user wishes to accept a call.
+     */
+    AcceptCall: Action.define("acceptCall", {
+      callType: String
+    }),
+
+    /**
+     * Signals when the user declines a call.
+     */
+    DeclineCall: Action.define("declineCall", {
+      blockCaller: Boolean
+    }),
+
+    /**
      * Used to initiate connecting of a call with the relevant
      * sessionData.
      */
     ConnectCall: Action.define("connectCall", {
       // This object contains the necessary details for the
       // connection of the websocket, and the SDK
       sessionData: Object
     }),
--- a/browser/components/loop/content/shared/js/conversationStore.js
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -216,16 +216,18 @@ loop.store = loop.store || {};
           windowType !== "incoming") {
         // Not for this store, don't do anything.
         return;
       }
 
       this.dispatcher.register(this, [
         "connectionFailure",
         "connectionProgress",
+        "acceptCall",
+        "declineCall",
         "connectCall",
         "hangupCall",
         "remotePeerDisconnected",
         "cancelCall",
         "retryCall",
         "mediaConnected",
         "setMute",
         "fetchRoomEmailLink",
@@ -251,16 +253,60 @@ loop.store = loop.store || {};
       if (this.getStoreState("outgoing")) {
         this._setupOutgoingCall();
       } else {
         this._setupIncomingCall();
       }
     },
 
     /**
+     * Accepts an incoming call.
+     *
+     * @param {sharedActions.AcceptCall} actionData
+     */
+    acceptCall: function(actionData) {
+      if (this.getStoreState("outgoing")) {
+        console.error("Received AcceptCall action in outgoing call state");
+        return;
+      }
+
+      this.setStoreState({
+        callType: actionData.callType,
+        videoMuted: actionData.callType === CALL_TYPES.AUDIO_ONLY
+      });
+
+      // Accepting the call on the websocket will bring us into the connecting
+      // state.
+      this._websocket.accept();
+    },
+
+    /**
+     * Declines an incoming call.
+     *
+     * @param {sharedActions.DeclineCall} actionData
+     */
+    declineCall: function(actionData) {
+      if (actionData.blockCaller) {
+        this.mozLoop.calls.blockDirectCaller(this.getStoreState("callerId"),
+          function(err) {
+            // XXX The conversation window will be closed when this cb is triggered
+            // figure out if there is a better way to report the error to the user
+            // (bug 1103150).
+            console.log(err.fileName + ":" + err.lineNumber + ": " + err.message);
+          });
+      }
+
+      this._websocket.decline();
+
+      // Now we've declined, end the session and close the window.
+      this._endSession();
+      this.setStoreState({callState: CALL_STATES.CLOSE});
+    },
+
+    /**
      * Handles the connect call action, this saves the appropriate
      * data and starts the connection for the websocket to notify the
      * server of progress.
      *
      * @param {sharedActions.ConnectCall} actionData The action data.
      */
     connectCall: function(actionData) {
       this.setStoreState(actionData.sessionData);
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -7,16 +7,17 @@ describe("loop.conversationViews", funct
   "use strict";
 
   var sharedUtils = loop.shared.utils;
   var sharedView = loop.shared.views;
   var sandbox, oldTitle, view, dispatcher, contact, fakeAudioXHR;
   var fakeMozLoop, fakeWindow;
 
   var CALL_STATES = loop.store.CALL_STATES;
+  var CALL_TYPES = loop.shared.utils.CALL_TYPES;
   var REST_ERRNOS = loop.shared.utils.REST_ERRNOS;
   var WEBSOCKET_REASONS = loop.shared.utils.WEBSOCKET_REASONS;
 
   // XXX refactor to Just Work with "sandbox.stubComponent" or else
   // just pass in the sandbox and put somewhere generally usable
 
   function stubComponent(obj, component, mockTagName){
     var reactClass = React.createClass({
@@ -1216,176 +1217,166 @@ describe("loop.conversationViews", funct
               sinon.assert.calledOnce(icView._websocket.mediaUp);
             });
         });
       });
     });
   });
 
   describe("AcceptCallView", function() {
-    var view, model, fakeAudio;
-
-    beforeEach(function() {
-      var Model = Backbone.Model.extend({
-        getCallIdentifier: function() {return "fakeId";}
-      });
-      model = new Model();
-      sandbox.spy(model, "trigger");
-      sandbox.stub(model, "set");
+    var view;
 
-      fakeAudio = {
-        play: sinon.spy(),
-        pause: sinon.spy(),
-        removeAttribute: sinon.spy()
-      };
-      sandbox.stub(window, "Audio").returns(fakeAudio);
+    function mountTestComponent(props) {
+      return TestUtils.renderIntoDocument(
+        React.createElement(loop.conversationViews.AcceptCallView, props));
+    }
 
-      view = TestUtils.renderIntoDocument(
-        React.createElement(loop.conversationViews.AcceptCallView, {
-          model: model,
-          video: true
-        }));
+    afterEach(function() {
+      view = null;
     });
 
     describe("default answer mode", function() {
       it("should display video as primary answer mode", function() {
-        view = TestUtils.renderIntoDocument(
-          React.createElement(loop.conversationViews.AcceptCallView, {
-            model: model,
-            video: true
-          }));
+        view = mountTestComponent({
+          callType: CALL_TYPES.AUDIO_VIDEO,
+          callerId: "fake@invalid.com",
+          dispatcher: dispatcher
+        });
+
         var primaryBtn = view.getDOMNode()
                                   .querySelector('.fx-embedded-btn-icon-video');
 
         expect(primaryBtn).not.to.eql(null);
       });
 
       it("should display audio as primary answer mode", function() {
-        view = TestUtils.renderIntoDocument(
-          React.createElement(loop.conversationViews.AcceptCallView, {
-            model: model,
-            video: false
-          }));
+        view = mountTestComponent({
+          callType: CALL_TYPES.AUDIO_ONLY,
+          callerId: "fake@invalid.com",
+          dispatcher: dispatcher
+        });
+
         var primaryBtn = view.getDOMNode()
                                   .querySelector('.fx-embedded-btn-icon-audio');
 
         expect(primaryBtn).not.to.eql(null);
       });
 
       it("should accept call with video", function() {
-        view = TestUtils.renderIntoDocument(
-          React.createElement(loop.conversationViews.AcceptCallView, {
-            model: model,
-            video: true
-          }));
+        view = mountTestComponent({
+          callType: CALL_TYPES.AUDIO_VIDEO,
+          callerId: "fake@invalid.com",
+          dispatcher: dispatcher
+        });
+
         var primaryBtn = view.getDOMNode()
                                   .querySelector('.fx-embedded-btn-icon-video');
 
         React.addons.TestUtils.Simulate.click(primaryBtn);
 
-        sinon.assert.calledOnce(model.set);
-        sinon.assert.calledWithExactly(model.set, "selectedCallType", "audio-video");
-        sinon.assert.calledOnce(model.trigger);
-        sinon.assert.calledWithExactly(model.trigger, "accept");
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.AcceptCall({
+            callType: CALL_TYPES.AUDIO_VIDEO
+          }));
       });
 
       it("should accept call with audio", function() {
-        view = TestUtils.renderIntoDocument(
-          React.createElement(loop.conversationViews.AcceptCallView, {
-            model: model,
-            video: false
-          }));
+        view = mountTestComponent({
+          callType: CALL_TYPES.AUDIO_ONLY,
+          callerId: "fake@invalid.com",
+          dispatcher: dispatcher
+        });
+
         var primaryBtn = view.getDOMNode()
                                   .querySelector('.fx-embedded-btn-icon-audio');
 
         React.addons.TestUtils.Simulate.click(primaryBtn);
 
-        sinon.assert.calledOnce(model.set);
-        sinon.assert.calledWithExactly(model.set, "selectedCallType", "audio");
-        sinon.assert.calledOnce(model.trigger);
-        sinon.assert.calledWithExactly(model.trigger, "accept");
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.AcceptCall({
+            callType: CALL_TYPES.AUDIO_ONLY
+          }));
       });
 
       it("should accept call with video when clicking on secondary btn",
-         function() {
-          view = TestUtils.renderIntoDocument(
-            React.createElement(loop.conversationViews.AcceptCallView, {
-              model: model,
-              video: false
-            }));
+        function() {
+          view = mountTestComponent({
+            callType: CALL_TYPES.AUDIO_ONLY,
+            callerId: "fake@invalid.com",
+            dispatcher: dispatcher
+          });
+
           var secondaryBtn = view.getDOMNode()
           .querySelector('.fx-embedded-btn-video-small');
 
           React.addons.TestUtils.Simulate.click(secondaryBtn);
 
-          sinon.assert.calledOnce(model.set);
-          sinon.assert.calledWithExactly(model.set, "selectedCallType", "audio-video");
-          sinon.assert.calledOnce(model.trigger);
-          sinon.assert.calledWithExactly(model.trigger, "accept");
-         });
+          sinon.assert.calledOnce(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.AcceptCall({
+              callType: CALL_TYPES.AUDIO_VIDEO
+            }));
+        });
 
       it("should accept call with audio when clicking on secondary btn",
-         function() {
-          view = TestUtils.renderIntoDocument(
-            React.createElement(loop.conversationViews.AcceptCallView, {
-              model: model,
-              video: true
-            }));
+        function() {
+          view = mountTestComponent({
+            callType: CALL_TYPES.AUDIO_VIDEO,
+            callerId: "fake@invalid.com",
+            dispatcher: dispatcher
+          });
+
           var secondaryBtn = view.getDOMNode()
           .querySelector('.fx-embedded-btn-audio-small');
 
           React.addons.TestUtils.Simulate.click(secondaryBtn);
 
-          sinon.assert.calledOnce(model.set);
-          sinon.assert.calledWithExactly(model.set, "selectedCallType", "audio");
-          sinon.assert.calledOnce(model.trigger);
-          sinon.assert.calledWithExactly(model.trigger, "accept");
-         });
-    });
-
-    describe("click event on .btn-accept", function() {
-      it("should trigger an 'accept' conversation model event", function () {
-        var buttonAccept = view.getDOMNode().querySelector(".btn-accept");
-        model.trigger.withArgs("accept");
-        TestUtils.Simulate.click(buttonAccept);
-
-        /* Setting a model property triggers 2 events */
-        sinon.assert.calledOnce(model.trigger.withArgs("accept"));
-      });
-
-      it("should set selectedCallType to audio-video", function () {
-        var buttonAccept = view.getDOMNode().querySelector(".btn-accept");
-
-        TestUtils.Simulate.click(buttonAccept);
-
-        sinon.assert.calledOnce(model.set);
-        sinon.assert.calledWithExactly(model.set, "selectedCallType",
-          "audio-video");
-      });
+          sinon.assert.calledOnce(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.AcceptCall({
+              callType: CALL_TYPES.AUDIO_ONLY
+            }));
+        });
     });
 
     describe("click event on .btn-decline", function() {
-      it("should trigger an 'decline' conversation model event", function() {
+      it("should dispatch a DeclineCall action", function() {
+        view = mountTestComponent({
+          callType: CALL_TYPES.AUDIO_VIDEO,
+          callerId: "fake@invalid.com",
+          dispatcher: dispatcher
+        });
+
         var buttonDecline = view.getDOMNode().querySelector(".btn-decline");
 
         TestUtils.Simulate.click(buttonDecline);
 
-        sinon.assert.calledOnce(model.trigger);
-        sinon.assert.calledWith(model.trigger, "decline");
-        });
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.DeclineCall({blockCaller: false}));
+      });
     });
 
     describe("click event on .btn-block", function() {
-      it("should trigger a 'block' conversation model event", function() {
+      it("should dispatch a DeclineCall action with blockCaller true", function() {
+        view = mountTestComponent({
+          callType: CALL_TYPES.AUDIO_VIDEO,
+          callerId: "fake@invalid.com",
+          dispatcher: dispatcher
+        });
+
         var buttonBlock = view.getDOMNode().querySelector(".btn-block");
 
         TestUtils.Simulate.click(buttonBlock);
 
-        sinon.assert.calledOnce(model.trigger);
-        sinon.assert.calledWith(model.trigger, "declineAndBlock");
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.DeclineCall({blockCaller: true}));
       });
     });
   });
 
   describe("GenericFailureView", function() {
     var view, fakeAudio;
 
     beforeEach(function() {
--- a/browser/components/loop/test/shared/conversationStore_test.js
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -3,16 +3,17 @@
 
 var expect = chai.expect;
 
 describe("loop.store.ConversationStore", function () {
   "use strict";
 
   var CALL_STATES = loop.store.CALL_STATES;
   var WS_STATES = loop.store.WS_STATES;
+  var CALL_TYPES = loop.shared.utils.CALL_TYPES;
   var WEBSOCKET_REASONS = loop.shared.utils.WEBSOCKET_REASONS;
   var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var sharedActions = loop.shared.actions;
   var sharedUtils = loop.shared.utils;
   var sandbox, dispatcher, client, store, fakeSessionData, sdkDriver;
   var contact, fakeMozLoop;
   var connectPromise, resolveConnectPromise, rejectConnectPromise;
   var wsCancelSpy, wsCloseSpy, wsMediaUpSpy, fakeWebsocket;
@@ -38,17 +39,18 @@ describe("loop.store.ConversationStore",
       }]
     };
 
     fakeMozLoop = {
       getLoopPref: sandbox.stub(),
       addConversationContext: sandbox.stub(),
       calls: {
         setCallInProgress: sandbox.stub(),
-        clearCallInProgress: sandbox.stub()
+        clearCallInProgress: sandbox.stub(),
+        blockDirectCaller: sandbox.stub()
       },
       rooms: {
         create: sandbox.stub()
       }
     };
 
     dispatcher = new loop.Dispatcher();
     client = {
@@ -490,16 +492,83 @@ describe("loop.store.ConversationStore",
             sinon.match.hasOwn("name", "connectionFailure"));
           sinon.assert.calledWithMatch(dispatcher.dispatch,
             sinon.match.hasOwn("reason", "setup"));
         });
       });
     });
   });
 
+  describe("#acceptCall", function() {
+    beforeEach(function() {
+      store._websocket = {
+        accept: sinon.stub()
+      };
+    });
+
+    it("should save the call type", function() {
+      store.acceptCall(
+        new sharedActions.AcceptCall({callType: CALL_TYPES.AUDIO_ONLY}));
+
+      expect(store.getStoreState("callType")).eql(CALL_TYPES.AUDIO_ONLY);
+      expect(store.getStoreState("videoMuted")).eql(true);
+    });
+
+    it("should call accept on the websocket", function() {
+      store.acceptCall(
+        new sharedActions.AcceptCall({callType: CALL_TYPES.AUDIO_ONLY}));
+
+      sinon.assert.calledOnce(store._websocket.accept);
+    });
+  });
+
+  describe("#declineCall", function() {
+    var fakeWebsocket;
+
+    beforeEach(function() {
+      fakeWebsocket = store._websocket = {
+        decline: sinon.stub(),
+        close: sinon.stub()
+      };
+
+      store.setStoreState({windowId: 42});
+    });
+
+    it("should block the caller if necessary", function() {
+      store.declineCall(new sharedActions.DeclineCall({blockCaller: true}));
+
+      sinon.assert.calledOnce(fakeMozLoop.calls.blockDirectCaller);
+    });
+
+    it("should call decline on the websocket", function() {
+      store.declineCall(new sharedActions.DeclineCall({blockCaller: false}));
+
+      sinon.assert.calledOnce(fakeWebsocket.decline);
+    });
+
+    it("should close the websocket", function() {
+      store.declineCall(new sharedActions.DeclineCall({blockCaller: false}));
+
+      sinon.assert.calledOnce(fakeWebsocket.close);
+    });
+
+    it("should clear the call in progress for the backend", function() {
+      store.declineCall(new sharedActions.DeclineCall({blockCaller: false}));
+
+      sinon.assert.calledOnce(fakeMozLoop.calls.clearCallInProgress);
+      sinon.assert.calledWithExactly(fakeMozLoop.calls.clearCallInProgress, 42);
+    });
+
+    it("should set the call state to CLOSE", function() {
+      store.declineCall(new sharedActions.DeclineCall({blockCaller: false}));
+
+      expect(store.getStoreState("callState")).eql(CALL_STATES.CLOSE);
+    });
+  });
+
   describe("#connectCall", function() {
     it("should save the call session data", function() {
       store.connectCall(
         new sharedActions.ConnectCall({sessionData: fakeSessionData}));
 
       expect(store.getStoreState("apiKey")).eql("fakeKey");
       expect(store.getStoreState("callId")).eql("142536");
       expect(store.getStoreState("sessionId")).eql("321456");