Bug 1423053 - Support accepting a payment request from the UI (with dummy data). r=jaws draft
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Wed, 20 Dec 2017 19:02:51 -0500
changeset 713803 46535f2ae0b2c7da136113d5215f29980ff44fe8
parent 712994 c0d0a7b36f2857de1a9af1ddfe6520c620e5d4b4
child 744433 3ed212275e21f792232092e005d49b74c2b21b84
push id93753
push usermozilla@noorenberghe.ca
push dateThu, 21 Dec 2017 00:03:23 +0000
reviewersjaws
bugs1423053
milestone59.0a1
Bug 1423053 - Support accepting a payment request from the UI (with dummy data). r=jaws MozReview-Commit-ID: 8OZzdvy1as
toolkit/components/payments/content/paymentDialog.js
toolkit/components/payments/res/containers/payment-dialog.js
toolkit/components/payments/res/paymentRequest.css
toolkit/components/payments/res/paymentRequest.js
toolkit/components/payments/res/paymentRequest.xhtml
toolkit/components/payments/test/PaymentTestUtils.jsm
toolkit/components/payments/test/browser/browser_show_dialog.js
toolkit/components/payments/test/unit/test_response_creation.js
toolkit/components/payments/test/unit/xpcshell.ini
--- a/toolkit/components/payments/content/paymentDialog.js
+++ b/toolkit/components/payments/content/paymentDialog.js
@@ -10,17 +10,17 @@
 "use strict";
 
 const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
 const paymentSrv = Cc["@mozilla.org/dom/payments/payment-request-service;1"]
                      .getService(Ci.nsIPaymentRequestService);
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
-let PaymentDialog = {
+var PaymentDialog = {
   componentsLoaded: new Map(),
   frame: null,
   mm: null,
   request: null,
 
   init(requestId, frame) {
     if (!requestId || typeof(requestId) != "string") {
       throw new Error("Invalid PaymentRequest ID");
@@ -33,31 +33,90 @@ let PaymentDialog = {
 
     this.frame = frame;
     this.mm = frame.frameLoader.messageManager;
     this.mm.addMessageListener("paymentContentToChrome", this);
     this.mm.loadFrameScript("chrome://payments/content/paymentDialogFrameScript.js", true);
     this.frame.src = "resource://payments/paymentRequest.xhtml";
   },
 
-  createShowResponse({acceptStatus, methodName = "", data = null,
-                      payerName = "", payerEmail = "", payerPhone = ""}) {
+  createShowResponse({
+    acceptStatus,
+    methodName = "",
+    methodData = null,
+    payerName = "",
+    payerEmail = "",
+    payerPhone = "",
+  }) {
     let showResponse = this.createComponentInstance(Ci.nsIPaymentShowActionResponse);
-    let methodData = this.createComponentInstance(Ci.nsIGeneralResponseData);
 
     showResponse.init(this.request.requestId,
                       acceptStatus,
                       methodName,
                       methodData,
                       payerName,
                       payerEmail,
                       payerPhone);
     return showResponse;
   },
 
+  createBasicCardResponseData({
+    cardholderName = "",
+    cardNumber,
+    expiryMonth = "",
+    expiryYear = "",
+    cardSecurityCode = "",
+    billingAddress = null,
+  }) {
+    const basicCardResponseData = Cc["@mozilla.org/dom/payments/basiccard-response-data;1"]
+                                  .createInstance(Ci.nsIBasicCardResponseData);
+    basicCardResponseData.initData(cardholderName,
+                                   cardNumber,
+                                   expiryMonth,
+                                   expiryYear,
+                                   cardSecurityCode,
+                                   billingAddress);
+    return basicCardResponseData;
+  },
+
+  createPaymentAddress({
+    country = "",
+    addressLines = [],
+    region = "",
+    city = "",
+    dependentLocality = "",
+    postalCode = "",
+    sortingCode = "",
+    languageCode = "",
+    organization = "",
+    recipient = "",
+    phone = "",
+  }) {
+    const billingAddress = Cc["@mozilla.org/dom/payments/payment-address;1"]
+                           .createInstance(Ci.nsIPaymentAddress);
+    const addressLine = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+    for (let line of addressLines) {
+      const address = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
+      address.data = line;
+      addressLine.appendElement(address);
+    }
+    billingAddress.init(country,
+                        addressLine,
+                        region,
+                        city,
+                        dependentLocality,
+                        postalCode,
+                        sortingCode,
+                        languageCode,
+                        organization,
+                        recipient,
+                        phone);
+    return billingAddress;
+  },
+
   createComponentInstance(componentInterface) {
     let componentName;
     switch (componentInterface) {
       case Ci.nsIPaymentShowActionResponse: {
         componentName = "@mozilla.org/dom/payments/payment-show-action-response;1";
         break;
       }
       case Ci.nsIGeneralResponseData: {
@@ -78,16 +137,35 @@ let PaymentDialog = {
   onPaymentCancel() {
     const showResponse = this.createShowResponse({
       acceptStatus: Ci.nsIPaymentActionResponse.PAYMENT_REJECTED,
     });
     paymentSrv.respondPayment(showResponse);
     window.close();
   },
 
+  pay({
+    payerName,
+    payerEmail,
+    payerPhone,
+    methodName,
+    methodData,
+  }) {
+    let basicCardData = this.createBasicCardResponseData(methodData);
+    const showResponse = this.createShowResponse({
+      acceptStatus: Ci.nsIPaymentActionResponse.PAYMENT_ACCEPTED,
+      payerName,
+      payerEmail,
+      payerPhone,
+      methodName,
+      methodData: basicCardData,
+    });
+    paymentSrv.respondPayment(showResponse);
+  },
+
   receiveMessage({data}) {
     let {messageType} = data;
 
     switch (messageType) {
       case "initializeRequest": {
         let requestSerialized = JSON.parse(JSON.stringify(this.request));
 
         // Manually serialize the nsIPrincipal.
@@ -105,15 +183,22 @@ let PaymentDialog = {
           },
         });
         break;
       }
       case "paymentCancel": {
         this.onPaymentCancel();
         break;
       }
+      case "pay": {
+        this.pay(data);
+        break;
+      }
     }
   },
 };
 
-let frame = document.getElementById("paymentRequestFrame");
-let requestId = (new URLSearchParams(window.location.search)).get("requestId");
-PaymentDialog.init(requestId, frame);
+if ("document" in this) {
+  // Running in a browser, not a unit test
+  let frame = document.getElementById("paymentRequestFrame");
+  let requestId = (new URLSearchParams(window.location.search)).get("requestId");
+  PaymentDialog.init(requestId, frame);
+}
--- a/toolkit/components/payments/res/containers/payment-dialog.js
+++ b/toolkit/components/payments/res/containers/payment-dialog.js
@@ -18,30 +18,47 @@ class PaymentDialog extends PaymentState
 
   connectedCallback() {
     let contents = document.importNode(this._template.content, true);
     this._hostNameEl = contents.querySelector("#host-name");
 
     this._cancelButton = contents.querySelector("#cancel");
     this._cancelButton.addEventListener("click", this.cancelRequest);
 
+    this._payButton = contents.querySelector("#pay");
+    this._payButton.addEventListener("click", this.pay);
+
     this.appendChild(contents);
 
     super.connectedCallback();
   }
 
   disconnectedCallback() {
     this._cancelButtonEl.removeEventListener("click", this.cancelRequest);
+    this._cancelButtonEl.removeEventListener("click", this.pay);
     super.disconnectedCallback();
   }
 
   cancelRequest() {
     PaymentRequest.cancel();
   }
 
+  pay() {
+    PaymentRequest.pay({
+      methodName: "basic-card",
+      methodData: {
+        cardholderName: "John Doe",
+        cardNumber: "9999999999",
+        expiryMonth: "01",
+        expiryYear: "9999",
+        cardSecurityCode: "999",
+      },
+    });
+  }
+
   setLoadingState(state) {
     this.requestStore.setState(state);
   }
 
   render(state) {
     let request = state.request;
     this._hostNameEl.textContent = request.topLevelPrincipal.URI.displayHost;
 
--- a/toolkit/components/payments/res/paymentRequest.css
+++ b/toolkit/components/payments/res/paymentRequest.css
@@ -15,14 +15,8 @@ html {
   margin: 5px;
   text-align: center;
 }
 
 #total .label {
   font-size: 15px;
   font-weight: bold;
 }
-
-#cancel {
-  position: absolute;
-  bottom: 10px;
-  left: 10px;
-}
--- a/toolkit/components/payments/res/paymentRequest.js
+++ b/toolkit/components/payments/res/paymentRequest.js
@@ -91,15 +91,19 @@ let PaymentRequest = {
       savedBasicCards: detail.savedBasicCards,
     });
   },
 
   cancel() {
     this.sendMessageToChrome("paymentCancel");
   },
 
+  pay(data) {
+    this.sendMessageToChrome("pay", data);
+  },
+
   onPaymentRequestUnload() {
     // remove listeners that may be used multiple times here
     window.removeEventListener("paymentChromeToContent", this);
   },
 };
 
 PaymentRequest.init();
--- a/toolkit/components/payments/res/paymentRequest.xhtml
+++ b/toolkit/components/payments/res/paymentRequest.xhtml
@@ -24,17 +24,18 @@
   <template id="payment-dialog-template">
     <div id="host-name"></div>
 
     <div id="total">
       <h2 class="label"></h2>
       <currency-amount></currency-amount>
     </div>
     <div id="controls-container">
-      <button id="cancel">Cancel payment</button>
+      <button id="cancel">Cancel</button>
+      <button id="pay">Pay</button>
     </div>
   </template>
 </head>
 <body>
   <iframe id="debugging-console" hidden="hidden" src="debugging.html"></iframe>
 
   <payment-dialog></payment-dialog>
 </body>
--- a/toolkit/components/payments/test/PaymentTestUtils.jsm
+++ b/toolkit/components/payments/test/PaymentTestUtils.jsm
@@ -6,16 +6,30 @@ const { classes: Cc, interfaces: Ci, res
 
 this.PaymentTestUtils = {
   /**
    * Common content tasks functions to be used with ContentTask.spawn.
    */
   ContentTasks: {
     /* eslint-env mozilla/frame-script */
     /**
+     * Add a completion handler to the existing `showPromise` to call .complete().
+     * @returns {Object} representing the PaymentResponse
+     */
+    addCompletionHandler: async () => {
+      let response = await content.showPromise;
+      response.complete();
+      return {
+        response: response.toJSON(),
+        // XXX: Bug NNN: workaround for `details` not being included in `toJSON`.
+        methodDetails: response.details,
+      };
+    },
+
+    /**
      * Create a new payment request and cache it as `rq`.
      *
      * @param {Object} args
      * @param {PaymentMethodData[]} methodData
      * @param {PaymentDetailsInit} details
      * @param {PaymentOptions} options
      */
     createRequest: ({methodData, details, options}) => {
@@ -29,30 +43,40 @@ this.PaymentTestUtils = {
      * @param {Object} args
      * @param {PaymentMethodData[]} methodData
      * @param {PaymentDetailsInit} details
      * @param {PaymentOptions} options
      */
     createAndShowRequest: ({methodData, details, options}) => {
       const rq = new content.PaymentRequest(methodData, details, options);
       content.rq = rq; // assign it so we can retrieve it later
-      rq.show();
+      content.showPromise = rq.show();
     },
+  },
 
+  DialogContentTasks: {
     /**
      * Click the cancel button
      *
      * Don't await on this task since the cancel can close the dialog before
      * ContentTask can resolve the promise.
      *
      * @returns {undefined}
      */
     manuallyClickCancel: () => {
       content.document.getElementById("cancel").click();
     },
+
+    /**
+     * Do the minimum possible to complete the payment succesfully.
+     * @returns {undefined}
+     */
+    completePayment: () => {
+      content.document.getElementById("pay").click();
+    },
   },
 
   /**
    * Common PaymentMethodData for testing
    */
   MethodData: {
     basicCard: {
       supportedMethods: "basic-card",
--- a/toolkit/components/payments/test/browser/browser_show_dialog.js
+++ b/toolkit/components/payments/test/browser/browser_show_dialog.js
@@ -39,12 +39,50 @@ add_task(async function test_show_manual
     ok(requestId, "requestId should be defined");
     is(win.closed, false, "dialog should not be closed");
 
     // abort the payment request manually
     let frame = await getPaymentFrame(win);
     ok(frame, "Got payment frame");
     await dialogReadyPromise;
     info("dialog ready");
-    spawnPaymentDialogTask(frame, PTU.ContentTasks.manuallyClickCancel);
+    spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
     await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
   });
 });
+
+add_task(async function test_show_completePayment() {
+  await BrowserTestUtils.withNewTab({
+    gBrowser,
+    url: BLANK_PAGE_URL,
+  }, async browser => {
+    let dialogReadyPromise = waitForWidgetReady();
+    // start by creating a PaymentRequest, and show it
+    await ContentTask.spawn(browser, {methodData, details}, PTU.ContentTasks.createAndShowRequest);
+
+    // get a reference to the UI dialog and the requestId
+    let [win] = await Promise.all([getPaymentWidget(), dialogReadyPromise]);
+    ok(win, "Got payment widget");
+    let requestId = paymentUISrv.requestIdForWindow(win);
+    ok(requestId, "requestId should be defined");
+    is(win.closed, false, "dialog should not be closed");
+
+    let frame = await getPaymentFrame(win);
+    ok(frame, "Got payment frame");
+    await dialogReadyPromise;
+    info("dialog ready, clicking pay");
+    spawnPaymentDialogTask(frame, PTU.DialogContentTasks.completePayment);
+
+    // Add a handler to complete the payment above.
+    info("acknowledging the completion from the merchant page");
+    let result = await ContentTask.spawn(browser, {}, PTU.ContentTasks.addCompletionHandler);
+    is(result.response.methodName, "basic-card", "Check methodName");
+
+    let methodDetails = result.methodDetails;
+    is(methodDetails.cardholderName, "John Doe", "Check cardholderName");
+    is(methodDetails.cardNumber, "9999999999", "Check cardNumber");
+    is(methodDetails.expiryMonth, "01", "Check expiryMonth");
+    is(methodDetails.expiryYear, "9999", "Check expiryYear");
+    is(methodDetails.cardSecurityCode, "999", "Check cardSecurityCode");
+
+    await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
+  });
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/test/unit/test_response_creation.js
@@ -0,0 +1,141 @@
+"use strict";
+
+/**
+ * Basic checks to ensure that helpers constructing responses map their
+ * destructured arguments properly to the `init` methods. Full testing of the init
+ * methods is left to the DOM code.
+ */
+
+/* import-globals-from ../../content/paymentDialog.js */
+let dialogGlobal = {};
+Services.scriptloader.loadSubScript("chrome://payments/content/paymentDialog.js", dialogGlobal);
+
+/**
+ * @param {Object} responseData with properties in the order matching `nsIBasicCardResponseData`
+ *                              init method args.
+ * @returns {string} serialized card data
+ */
+function serializeBasicCardResponseData(responseData) {
+  return [...Object.entries(responseData)].map(array => array.join(":")).join(";") + ";";
+}
+
+
+add_task(async function test_createBasicCardResponseData_basic() {
+  let expected = {
+    cardholderName: "John Smith",
+    cardNumber: "1234567890",
+    expiryMonth: "01",
+    expiryYear: "2017",
+    cardSecurityCode: "0123",
+  };
+  let actual = dialogGlobal.PaymentDialog.createBasicCardResponseData(expected);
+  let expectedSerialized = serializeBasicCardResponseData(expected);
+  do_check_eq(actual.data, expectedSerialized, "Check data");
+});
+
+add_task(async function test_createBasicCardResponseData_minimal() {
+  let expected = {
+    cardNumber: "1234567890",
+  };
+  let actual = dialogGlobal.PaymentDialog.createBasicCardResponseData(expected);
+  let expectedSerialized = serializeBasicCardResponseData(expected);
+  do_print(actual.data);
+  do_check_eq(actual.data, expectedSerialized, "Check data");
+});
+
+add_task(async function test_createBasicCardResponseData_withoutNumber() {
+  let data = {
+    cardholderName: "John Smith",
+    expiryMonth: "01",
+    expiryYear: "2017",
+    cardSecurityCode: "0123",
+  };
+  Assert.throws(() => dialogGlobal.PaymentDialog.createBasicCardResponseData(data),
+                /NS_ERROR_FAILURE/,
+                "Check cardNumber is required");
+});
+
+function checkAddress(actual, expected) {
+  for (let [propName, propVal] of Object.entries(expected)) {
+    if (propName == "addressLines") {
+      // Note the singular vs. plural here.
+      do_check_eq(actual.addressLine.length, propVal.length, "Check number of address lines");
+      for (let [i, line] of expected.addressLines.entries()) {
+        do_check_eq(actual.addressLine.queryElementAt(i, Ci.nsISupportsString).data, line,
+                    `Check ${propName} line ${i}`);
+      }
+      continue;
+    }
+    do_check_eq(actual[propName], propVal, `Check ${propName}`);
+  }
+}
+
+add_task(async function test_createPaymentAddress_minimal() {
+  let data = {
+    country: "CA",
+  };
+  let actual = dialogGlobal.PaymentDialog.createPaymentAddress(data);
+  checkAddress(actual, data);
+});
+
+add_task(async function test_createPaymentAddress_basic() {
+  let data = {
+    country: "CA",
+    addressLines: [
+      "123 Sesame Street",
+      "P.O. Box ABC",
+    ],
+    region: "ON",
+    city: "Delhi",
+    dependentLocality: "N/A",
+    postalCode: "94041",
+    sortingCode: "1234",
+    languageCode: "en-CA",
+    organization: "Mozilla Corporation",
+    recipient: "John Smith",
+    phone: "+15195555555",
+  };
+  let actual = dialogGlobal.PaymentDialog.createPaymentAddress(data);
+  checkAddress(actual, data);
+});
+
+add_task(async function test_createShowResponse_basic() {
+  let requestId = "876hmbvfd45hb";
+  dialogGlobal.PaymentDialog.request = {
+    requestId,
+  };
+
+  let cardData = {
+    cardholderName: "John Smith",
+    cardNumber: "1234567890",
+    expiryMonth: "01",
+    expiryYear: "2099",
+    cardSecurityCode: "0123",
+  };
+  let methodData = dialogGlobal.PaymentDialog.createBasicCardResponseData(cardData);
+
+  let responseData = {
+    acceptStatus: Ci.nsIPaymentActionResponse.PAYMENT_ACCEPTED,
+    methodName: "basic-card",
+    methodData,
+    payerName: "My Name",
+    payerEmail: "my.email@example.com",
+    payerPhone: "+15195555555",
+  };
+  let actual = dialogGlobal.PaymentDialog.createShowResponse(responseData);
+  for (let [propName, propVal] of Object.entries(actual)) {
+    if (typeof(propVal) != "string") {
+      continue;
+    }
+    if (propName == "requestId") {
+      do_check_eq(propVal, requestId, `Check ${propName}`);
+      continue;
+    }
+    if (propName == "data") {
+      do_check_eq(propVal, serializeBasicCardResponseData(cardData), `Check ${propName}`);
+      continue;
+    }
+
+    do_check_eq(propVal, responseData[propName], `Check ${propName}`);
+  }
+});
--- a/toolkit/components/payments/test/unit/xpcshell.ini
+++ b/toolkit/components/payments/test/unit/xpcshell.ini
@@ -1,4 +1,5 @@
 [DEFAULT]
 head = head.js
 
 [test_PaymentsStore.js]
+[test_response_creation.js]