Bug 1402963 - Part 2: Merge the credit card record into existing data. r=lchang
authorsteveck-chung <schung@mozilla.com>
Wed, 25 Oct 2017 17:46:56 +0800
changeset 443946 8dbc7c83b48762894fd1e02fa1032bfc1a3fdb73
parent 443945 3eef0753a078b9de3ad6d0cd98c96e5f345edc5a
child 443947 78f97c364749691c184361eef850a59198cf011f
push id1618
push userCallek@gmail.com
push dateThu, 11 Jan 2018 17:45:48 +0000
treeherdermozilla-release@882ca853e05a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerslchang
bugs1402963
milestone58.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 1402963 - Part 2: Merge the credit card record into existing data. r=lchang MozReview-Commit-ID: 3Hkqvo2rK9R
browser/extensions/formautofill/FormAutofillParent.jsm
browser/extensions/formautofill/ProfileStorage.jsm
browser/extensions/formautofill/test/browser/browser_creditCard_doorhanger.js
browser/extensions/formautofill/test/browser/head.js
browser/extensions/formautofill/test/unit/test_creditCardRecords.js
--- a/browser/extensions/formautofill/FormAutofillParent.jsm
+++ b/browser/extensions/formautofill/FormAutofillParent.jsm
@@ -433,17 +433,22 @@ FormAutofillParent.prototype = {
     //   - User applys autofill and changed
     //   - User fills form manually and the filling data is not duplicated to storage
     if (creditCard.guid) {
       let originalCCData = this.profileStorage.creditCards.get(creditCard.guid);
       let unchanged = Object.keys(creditCard.record).every(field => {
         if (creditCard.record[field] === "" && !originalCCData[field]) {
           return true;
         }
-        return creditCard.untouchedFields.includes(field);
+        // Avoid updating the fields that users don't modify.
+        let untouched = creditCard.untouchedFields.includes(field);
+        if (untouched) {
+          creditCard.record[field] = originalCCData[field];
+        }
+        return untouched;
       });
 
       if (unchanged) {
         this.profileStorage.creditCards.notifyUsed(creditCard.guid);
         // Add probe to record credit card autofill(without modification).
         Services.telemetry.scalarAdd("formautofill.creditCards.fill_type_autofill", 1);
         this._recordFormFillingTime("creditCard", "autofill", timeStartedFillingMS);
         return;
@@ -476,17 +481,28 @@ FormAutofillParent.prototype = {
 
     // TODO: "MasterPassword.ensureLoggedIn" can be removed after the storage
     // APIs are refactored to be async functions (bug 1399367).
     if (!await MasterPassword.ensureLoggedIn()) {
       log.warn("User canceled master password entry");
       return;
     }
 
-    this.profileStorage.creditCards.add(creditCard.record);
+    let changedGUIDs = [];
+    // TODO: Autofill(with guid) case should show update doorhanger with update/create new.
+    // It'll be implemented in bug 1403881 and only avoid mergering for now.
+    if (creditCard.guid) {
+      changedGUIDs.push(this.profileStorage.creditCards.add(creditCard.record));
+    } else {
+      changedGUIDs.push(...this.profileStorage.creditCards.mergeToStorage(creditCard.record));
+      if (!changedGUIDs.length) {
+        changedGUIDs.push(this.profileStorage.creditCards.add(creditCard.record));
+      }
+    }
+    changedGUIDs.forEach(guid => this.profileStorage.creditCards.notifyUsed(guid));
   },
 
   _onFormSubmit(data, target) {
     let {profile: {address, creditCard}, timeStartedFillingMS} = data;
 
     if (address) {
       this._onAddressSubmit(address, target, timeStartedFillingMS);
     }
--- a/browser/extensions/formautofill/ProfileStorage.jsm
+++ b/browser/extensions/formautofill/ProfileStorage.jsm
@@ -1647,16 +1647,71 @@ class CreditCards extends AutofillRecord
         return clonedTargetCreditCard[field] == creditCard[field];
       });
       if (isDuplicate) {
         return creditCard.guid;
       }
     }
     return null;
   }
+
+  /**
+   * Merge new credit card into the specified record if cc-number is identical.
+   *
+   * @param  {string} guid
+   *         Indicates which credit card to merge.
+   * @param  {Object} creditCard
+   *         The new credit card used to merge into the old one.
+   * @returns {boolean}
+   *          Return true if credit card is merged into target with specific guid or false if not.
+   */
+  mergeIfPossible(guid, creditCard) {
+    this.log.debug("mergeIfPossible:", guid, creditCard);
+
+    // Query raw data for comparing the decrypted credit card number
+    let creditCardFound = this.get(guid, {rawData: true});
+    if (!creditCardFound) {
+      throw new Error("No matching credit card.");
+    }
+
+    let creditCardToMerge = this._cloneAndCleanUp(creditCard);
+    this._normalizeRecord(creditCardToMerge);
+
+    for (let field of this.VALID_FIELDS) {
+      let existingField = creditCardFound[field];
+
+      // Make sure credit card field is existed and have value
+      if (field == "cc-number" && (!existingField || !creditCardToMerge[field])) {
+        return false;
+      }
+
+      if (!creditCardToMerge[field]) {
+        creditCardToMerge[field] = existingField;
+      }
+
+      let incomingField = creditCardToMerge[field];
+      if (incomingField && existingField) {
+        if (incomingField != existingField) {
+          this.log.debug("Conflicts: field", field, "has different value.");
+          return false;
+        }
+      }
+    }
+
+    // Early return if the data is the same.
+    let exactlyMatch = this.VALID_FIELDS.every((field) =>
+      creditCardFound[field] === creditCardToMerge[field]
+    );
+    if (exactlyMatch) {
+      return true;
+    }
+
+    this.update(guid, creditCardToMerge, true);
+    return true;
+  }
 }
 
 function ProfileStorage(path) {
   this._path = path;
   this._initializePromise = null;
   this.INTERNAL_FIELDS = INTERNAL_FIELDS;
 }
 
--- a/browser/extensions/formautofill/test/browser/browser_creditCard_doorhanger.js
+++ b/browser/extensions/formautofill/test/browser/browser_creditCard_doorhanger.js
@@ -57,19 +57,23 @@ add_task(async function test_submit_cred
       await promiseShown;
       await clickDoorhangerButton(MAIN_BUTTON);
     }
   );
 
   let creditCards = await getCreditCards();
   is(creditCards.length, 1, "1 credit card in storage");
   is(creditCards[0]["cc-name"], "User 1", "Verify the name field");
+  await removeAllRecords();
 });
 
 add_task(async function test_submit_untouched_creditCard_form() {
+  await saveCreditCard(TEST_CREDIT_CARD_1);
+  let creditCards = await getCreditCards();
+  is(creditCards.length, 1, "1 credit card in storage");
   await BrowserTestUtils.withNewTab({gBrowser, url: CREDITCARD_FORM_URL},
     async function(browser) {
       await openPopupOn(browser, "form #cc-name");
       await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
       await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
 
@@ -78,109 +82,121 @@ add_task(async function test_submit_unto
         form.querySelector("input[type=submit]").click();
       });
 
       await sleep(1000);
       is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");
     }
   );
 
-  let creditCards = await getCreditCards();
-  is(creditCards.length, 1, "Still 1 credit card in storage");
-  is(creditCards[0]["cc-name"], "User 1", "Verify the name field");
+  creditCards = await getCreditCards();
+  is(creditCards.length, 1, "Still 1 credit card");
   is(creditCards[0].timesUsed, 1, "timesUsed field set to 1");
+  await removeAllRecords();
 });
 
-add_task(async function test_submit_changed_creditCard_form() {
+add_task(async function test_submit_changed_subset_creditCard_form() {
+  await saveCreditCard(TEST_CREDIT_CARD_1);
+  let creditCards = await getCreditCards();
+  is(creditCards.length, 1, "1 credit card in storage");
   await BrowserTestUtils.withNewTab({gBrowser, url: CREDITCARD_FORM_URL},
     async function(browser) {
       let promiseShown = BrowserTestUtils.waitForEvent(PopupNotifications.panel,
                                                        "popupshown");
-      await openPopupOn(browser, "form #cc-name");
-      await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
-      await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let name = form.querySelector("#cc-name");
 
         name.focus();
         await new Promise(resolve => setTimeout(resolve, 1000));
         name.setUserInput("");
+
+        form.querySelector("#cc-number").setUserInput("1234567812345678");
+        form.querySelector("#cc-exp-month").setUserInput("4");
+        form.querySelector("#cc-exp-year").setUserInput("2017");
         // Wait 1000ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 1000));
         form.querySelector("input[type=submit]").click();
       });
 
       await promiseShown;
-      await clickDoorhangerButton(SECONDARY_BUTTON);
+      await clickDoorhangerButton(MAIN_BUTTON);
     }
   );
 
-  let creditCards = await getCreditCards();
+  creditCards = await getCreditCards();
   is(creditCards.length, 1, "Still 1 credit card in storage");
-  is(creditCards[0]["cc-name"], "User 1", "Verify the name field");
+  is(creditCards[0]["cc-name"], TEST_CREDIT_CARD_1["cc-name"], "name field still exists");
+  await removeAllRecords();
 });
 
 add_task(async function test_submit_duplicate_creditCard_form() {
+  await saveCreditCard(TEST_CREDIT_CARD_1);
+  let creditCards = await getCreditCards();
+  is(creditCards.length, 1, "1 credit card in storage");
   await BrowserTestUtils.withNewTab({gBrowser, url: CREDITCARD_FORM_URL},
     async function(browser) {
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let name = form.querySelector("#cc-name");
         name.focus();
 
-        name.setUserInput("User 1");
-        form.querySelector("#cc-number").setUserInput("1111222233334444");
-        form.querySelector("#cc-exp-month").setUserInput("12");
+        name.setUserInput("John Doe");
+        form.querySelector("#cc-number").setUserInput("1234567812345678");
+        form.querySelector("#cc-exp-month").setUserInput("4");
         form.querySelector("#cc-exp-year").setUserInput("2017");
 
         // Wait 1000ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 1000));
         form.querySelector("input[type=submit]").click();
       });
 
       await sleep(1000);
       is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");
     }
   );
 
-  let creditCards = await getCreditCards();
+  creditCards = await getCreditCards();
   is(creditCards.length, 1, "Still 1 credit card in storage");
-  is(creditCards[0]["cc-name"], "User 1", "Verify the name field");
+  is(creditCards[0]["cc-name"], TEST_CREDIT_CARD_1["cc-name"], "Verify the name field");
   is(creditCards[0].timesUsed, 1, "timesUsed field set to 1");
+  await removeAllRecords();
 });
 
 add_task(async function test_submit_unnormailzed_creditCard_form() {
+  await saveCreditCard(TEST_CREDIT_CARD_1);
+  let creditCards = await getCreditCards();
+  is(creditCards.length, 1, "1 credit card in storage");
   await BrowserTestUtils.withNewTab({gBrowser, url: CREDITCARD_FORM_URL},
     async function(browser) {
       await ContentTask.spawn(browser, null, async function() {
         let form = content.document.getElementById("form");
         let name = form.querySelector("#cc-name");
         name.focus();
 
-        name.setUserInput("User 1");
-
-        form.querySelector("#cc-number").setUserInput("1111222233334444");
-        form.querySelector("#cc-exp-month").setUserInput("12");
+        name.setUserInput("John Doe");
+        form.querySelector("#cc-number").setUserInput("1234567812345678");
+        form.querySelector("#cc-exp-month").setUserInput("4");
         // Set unnormalized year
         form.querySelector("#cc-exp-year").setUserInput("17");
 
         // Wait 1000ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 1000));
         form.querySelector("input[type=submit]").click();
       });
 
       await sleep(1000);
       is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");
     }
   );
 
-  let creditCards = await getCreditCards();
+  creditCards = await getCreditCards();
   is(creditCards.length, 1, "Still 1 credit card in storage");
   is(creditCards[0]["cc-exp-year"], "2017", "Verify the expiry year field");
+  await removeAllRecords();
 });
 
 add_task(async function test_submit_creditCard_never_save() {
   await BrowserTestUtils.withNewTab({gBrowser, url: CREDITCARD_FORM_URL},
     async function(browser) {
       let promiseShown = BrowserTestUtils.waitForEvent(PopupNotifications.panel,
                                                        "popupshown");
       await ContentTask.spawn(browser, null, async function() {
@@ -201,17 +217,17 @@ add_task(async function test_submit_cred
       await promiseShown;
       await clickDoorhangerButton(MENU_BUTTON, 0);
     }
   );
 
   await sleep(1000);
   let creditCards = await getCreditCards();
   let creditCardPref = SpecialPowers.getBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF);
-  is(creditCards.length, 1, "Still 1 credit card in storage");
+  is(creditCards.length, 0, "No credit card in storage");
   is(creditCardPref, false, "Credit card is disabled");
   SpecialPowers.clearUserPref(ENABLED_AUTOFILL_CREDITCARDS_PREF);
 });
 
 add_task(async function test_submit_creditCard_saved_with_mp_enabled() {
   LoginTestUtils.masterPassword.enable();
   // Login with the masterPassword in LoginTestUtils.
   let masterPasswordDialogShown = waitForMasterPasswordDialog(true);
@@ -237,20 +253,21 @@ add_task(async function test_submit_cred
       await promiseShown;
       await clickDoorhangerButton(MAIN_BUTTON);
       await masterPasswordDialogShown;
       await TestUtils.topicObserved("formautofill-storage-changed");
     }
   );
 
   let creditCards = await getCreditCards();
-  is(creditCards.length, 2, "2 credit cards in storage");
-  is(creditCards[1]["cc-name"], "User 0", "Verify the name field");
-  is(creditCards[1]["cc-number"], "************1234", "Verify the card number field");
+  is(creditCards.length, 1, "1 credit card in storage");
+  is(creditCards[0]["cc-name"], "User 0", "Verify the name field");
+  is(creditCards[0]["cc-number"], "************1234", "Verify the card number field");
   LoginTestUtils.masterPassword.disable();
+  await removeAllRecords();
 });
 
 add_task(async function test_submit_creditCard_saved_with_mp_enabled_but_canceled() {
   LoginTestUtils.masterPassword.enable();
   let masterPasswordDialogShown = waitForMasterPasswordDialog();
   await BrowserTestUtils.withNewTab({gBrowser, url: CREDITCARD_FORM_URL},
     async function(browser) {
       let promiseShown = BrowserTestUtils.waitForEvent(PopupNotifications.panel,
@@ -273,17 +290,17 @@ add_task(async function test_submit_cred
       await promiseShown;
       await clickDoorhangerButton(MAIN_BUTTON);
       await masterPasswordDialogShown;
     }
   );
 
   await sleep(1000);
   let creditCards = await getCreditCards();
-  is(creditCards.length, 2, "Still 2 credit cards in storage");
+  is(creditCards.length, 0, "No credit cards in storage");
   LoginTestUtils.masterPassword.disable();
 });
 
 add_task(async function test_submit_creditCard_with_sync_account() {
   await SpecialPowers.pushPrefEnv({
     "set": [
       [SYNC_USERNAME_PREF, "foo@bar.com"],
       [SYNC_CREDITCARDS_AVAILABLE_PREF, true],
@@ -327,17 +344,17 @@ add_task(async function test_submit_cred
       is(secondaryButton.disabled, true, "Not saving button should be disabled");
       is(menuButton.disabled, true, "Never saving menu button should be disabled");
       // Click the checkbox again to disable credit card sync.
       cb.click();
       is(SpecialPowers.getBoolPref(SYNC_CREDITCARDS_PREF), false,
          "creditCards sync should be disabled after unchecked");
       is(secondaryButton.disabled, false, "Not saving button should be enabled again");
       is(menuButton.disabled, false, "Never saving menu button should be enabled again");
-      await clickDoorhangerButton(MAIN_BUTTON);
+      await clickDoorhangerButton(SECONDARY_BUTTON);
     }
   );
 });
 
 add_task(async function test_submit_creditCard_with_synced_already() {
   await SpecialPowers.pushPrefEnv({
     "set": [
       [SYNC_CREDITCARDS_PREF, true],
@@ -362,12 +379,45 @@ add_task(async function test_submit_cred
         // Wait 500ms before submission to make sure the input value applied
         await new Promise(resolve => setTimeout(resolve, 500));
         form.querySelector("input[type=submit]").click();
       });
 
       await promiseShown;
       let cb = getDoorhangerCheckbox();
       ok(cb.hidden, "Sync checkbox should be hidden");
+      await clickDoorhangerButton(SECONDARY_BUTTON);
+    }
+  );
+});
+
+add_task(async function test_submit_manual_mergeable_creditCard_form() {
+  await saveCreditCard(TEST_CREDIT_CARD_3);
+  let creditCards = await getCreditCards();
+  is(creditCards.length, 1, "1 credit card in storage");
+  await BrowserTestUtils.withNewTab({gBrowser, url: CREDITCARD_FORM_URL},
+    async function(browser) {
+      let promiseShown = BrowserTestUtils.waitForEvent(PopupNotifications.panel,
+                                                       "popupshown");
+      await ContentTask.spawn(browser, null, async function() {
+        let form = content.document.getElementById("form");
+        let name = form.querySelector("#cc-name");
+        name.focus();
+
+        name.setUserInput("User 3");
+        form.querySelector("#cc-number").setUserInput("9999888877776666");
+        form.querySelector("#cc-exp-month").setUserInput("1");
+        form.querySelector("#cc-exp-year").setUserInput("2000");
+
+        // Wait 1000ms before submission to make sure the input value applied
+        await new Promise(resolve => setTimeout(resolve, 1000));
+        form.querySelector("input[type=submit]").click();
+      });
+      await promiseShown;
       await clickDoorhangerButton(MAIN_BUTTON);
     }
   );
+
+  creditCards = await getCreditCards();
+  is(creditCards.length, 1, "Still 1 credit card in storage");
+  is(creditCards[0]["cc-name"], "User 3", "Verify the name field");
+  await removeAllRecords();
 });
--- a/browser/extensions/formautofill/test/browser/head.js
+++ b/browser/extensions/formautofill/test/browser/head.js
@@ -1,17 +1,17 @@
 /* exported MANAGE_ADDRESSES_DIALOG_URL, MANAGE_CREDIT_CARDS_DIALOG_URL, EDIT_ADDRESS_DIALOG_URL, EDIT_CREDIT_CARD_DIALOG_URL,
             BASE_URL, TEST_ADDRESS_1, TEST_ADDRESS_2, TEST_ADDRESS_3, TEST_ADDRESS_4, TEST_ADDRESS_5,
             TEST_CREDIT_CARD_1, TEST_CREDIT_CARD_2, TEST_CREDIT_CARD_3, FORM_URL, CREDITCARD_FORM_URL,
             FTU_PREF, ENABLED_AUTOFILL_ADDRESSES_PREF, AUTOFILL_CREDITCARDS_AVAILABLE_PREF, ENABLED_AUTOFILL_CREDITCARDS_PREF,
             SYNC_USERNAME_PREF, SYNC_ADDRESSES_PREF, SYNC_CREDITCARDS_PREF, SYNC_CREDITCARDS_AVAILABLE_PREF,
             sleep, expectPopupOpen, openPopupOn, expectPopupClose, closePopup, clickDoorhangerButton,
             getAddresses, saveAddress, removeAddresses, saveCreditCard,
             getDisplayedPopupItems, getDoorhangerCheckbox, waitForMasterPasswordDialog,
-            getNotification, getDoorhangerButton */
+            getNotification, getDoorhangerButton, removeAllRecords */
 
 "use strict";
 
 Cu.import("resource://testing-common/LoginTestUtils.jsm", this);
 
 const MANAGE_ADDRESSES_DIALOG_URL = "chrome://formautofill/content/manageAddresses.xhtml";
 const MANAGE_CREDIT_CARDS_DIALOG_URL = "chrome://formautofill/content/manageCreditCards.xhtml";
 const EDIT_ADDRESS_DIALOG_URL = "chrome://formautofill/content/editAddress.xhtml";
@@ -282,19 +282,21 @@ function waitForMasterPasswordDialog(log
       dialog.ui.password1Textbox.value = LoginTestUtils.masterPassword.masterPassword;
       dialog.ui.button0.click();
     } else {
       dialog.ui.button1.click();
     }
   });
 }
 
-registerCleanupFunction(async function() {
+async function removeAllRecords() {
   let addresses = await getAddresses();
   if (addresses.length) {
     await removeAddresses(addresses.map(address => address.guid));
   }
 
   let creditCards = await getCreditCards();
   if (creditCards.length) {
     await removeCreditCards(creditCards.map(cc => cc.guid));
   }
-});
+}
+
+registerCleanupFunction(removeAllRecords);
--- a/browser/extensions/formautofill/test/unit/test_creditCardRecords.js
+++ b/browser/extensions/formautofill/test/unit/test_creditCardRecords.js
@@ -23,16 +23,21 @@ const TEST_CREDIT_CARD_2 = {
 };
 
 const TEST_CREDIT_CARD_3 = {
   "cc-number": "9999888877776666",
   "cc-exp-month": 1,
   "cc-exp-year": 2000,
 };
 
+const TEST_CREDIT_CARD_4 = {
+  "cc-name": "Foo Bar",
+  "cc-number": "9999888877776666",
+};
+
 const TEST_CREDIT_CARD_WITH_EMPTY_FIELD = {
   "cc-name": "",
   "cc-number": "1234123412341234",
   "cc-exp-month": 1,
 };
 
 const TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR = {
   "cc-number": "1234123412341234",
@@ -53,16 +58,78 @@ const TEST_CREDIT_CARD_WITH_INVALID_EXPI
   "cc-exp-year": -3,
 };
 
 const TEST_CREDIT_CARD_WITH_SPACES_BETWEEN_DIGITS = {
   "cc-name": "John Doe",
   "cc-number": "1111 2222 3333 4444",
 };
 
+const MERGE_TESTCASES = [
+  {
+    description: "Merge a superset",
+    creditCardInStorage: {
+      "cc-number": "1234567812345678",
+      "cc-exp-month": 4,
+      "cc-exp-year": 2017,
+    },
+    creditCardToMerge: {
+      "cc-name": "John Doe",
+      "cc-number": "1234567812345678",
+      "cc-exp-month": 4,
+      "cc-exp-year": 2017,
+    },
+    expectedCreditCard: {
+      "cc-name": "John Doe",
+      "cc-number": "1234567812345678",
+      "cc-exp-month": 4,
+      "cc-exp-year": 2017,
+    },
+  },
+  {
+    description: "Merge a subset",
+    creditCardInStorage: {
+      "cc-name": "John Doe",
+      "cc-number": "1234567812345678",
+      "cc-exp-month": 4,
+      "cc-exp-year": 2017,
+    },
+    creditCardToMerge: {
+      "cc-number": "1234567812345678",
+      "cc-exp-month": 4,
+      "cc-exp-year": 2017,
+    },
+    expectedCreditCard: {
+      "cc-name": "John Doe",
+      "cc-number": "1234567812345678",
+      "cc-exp-month": 4,
+      "cc-exp-year": 2017,
+    },
+    noNeedToUpdate: true,
+  },
+  {
+    description: "Merge an creditCard with partial overlaps",
+    creditCardInStorage: {
+      "cc-name": "John Doe",
+      "cc-number": "1234567812345678",
+    },
+    creditCardToMerge: {
+      "cc-number": "1234567812345678",
+      "cc-exp-month": 4,
+      "cc-exp-year": 2017,
+    },
+    expectedCreditCard: {
+      "cc-name": "John Doe",
+      "cc-number": "1234567812345678",
+      "cc-exp-month": 4,
+      "cc-exp-year": 2017,
+    },
+  },
+];
+
 let prepareTestCreditCards = async function(path) {
   let profileStorage = new ProfileStorage(path);
   await profileStorage.initialize();
 
   let onChanged = TestUtils.topicObserved("formautofill-storage-changed",
                                           (subject, data) => data == "add");
   do_check_true(profileStorage.creditCards.add(TEST_CREDIT_CARD_1));
   await onChanged;
@@ -310,8 +377,86 @@ add_task(async function test_remove() {
   await profileStorage.initialize();
 
   creditCards = profileStorage.creditCards.getAll();
 
   do_check_eq(creditCards.length, 1);
 
   do_check_eq(profileStorage.creditCards.get(guid), null);
 });
+
+MERGE_TESTCASES.forEach((testcase) => {
+  add_task(async function test_merge() {
+    do_print("Starting testcase: " + testcase.description);
+    let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+                                                  [testcase.creditCardInStorage],
+                                                  "creditCards");
+    let creditCards = profileStorage.creditCards.getAll();
+    let guid = creditCards[0].guid;
+    let timeLastModified = creditCards[0].timeLastModified;
+    // Merge creditCard and verify the guid in notifyObservers subject
+    let onMerged = TestUtils.topicObserved(
+      "formautofill-storage-changed",
+      (subject, data) =>
+        data == "update" && subject.QueryInterface(Ci.nsISupportsString).data == guid
+    );
+    // Force to create sync metadata.
+    profileStorage.creditCards.pullSyncChanges();
+    do_check_eq(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+    Assert.ok(profileStorage.creditCards.mergeIfPossible(guid, testcase.creditCardToMerge));
+    if (!testcase.noNeedToUpdate) {
+      await onMerged;
+    }
+    creditCards = profileStorage.creditCards.getAll();
+    Assert.equal(creditCards.length, 1);
+    do_check_credit_card_matches(creditCards[0], testcase.expectedCreditCard);
+    if (!testcase.noNeedToUpdate) {
+      // Record merging should update timeLastModified and bump the change counter.
+      Assert.notEqual(creditCards[0].timeLastModified, timeLastModified);
+      do_check_eq(getSyncChangeCounter(profileStorage.creditCards, guid), 2);
+    } else {
+      // Subset record merging should not update timeLastModified and the change
+      // counter is still the same.
+      Assert.equal(creditCards[0].timeLastModified, timeLastModified);
+      do_check_eq(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+    }
+  });
+});
+
+add_task(async function test_merge_unable_merge() {
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+                                                [TEST_CREDIT_CARD_1],
+                                                "creditCards");
+
+  let creditCards = profileStorage.creditCards.getAll();
+  let guid = creditCards[0].guid;
+  // Force to create sync metadata.
+  profileStorage.creditCards.pullSyncChanges();
+  do_check_eq(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+
+  // Unable to merge because of conflict
+  let anotherCreditCard = profileStorage.creditCards._clone(TEST_CREDIT_CARD_1);
+  anotherCreditCard["cc-name"] = "Foo Bar";
+  do_check_eq(profileStorage.creditCards.mergeIfPossible(guid, anotherCreditCard), false);
+  // The change counter is unchanged.
+  do_check_eq(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+
+  // Unable to merge because no credit card number
+  anotherCreditCard = profileStorage.creditCards._clone(TEST_CREDIT_CARD_1);
+  anotherCreditCard["cc-number"] = "";
+  do_check_eq(profileStorage.creditCards.mergeIfPossible(guid, anotherCreditCard), false);
+  // The change counter is still unchanged.
+  do_check_eq(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+});
+
+add_task(async function test_mergeToStorage() {
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+                                                [TEST_CREDIT_CARD_3, TEST_CREDIT_CARD_4],
+                                                "creditCards");
+  // Merge a creditCard to storage
+  let anotherCreditCard = profileStorage.creditCards._clone(TEST_CREDIT_CARD_3);
+  anotherCreditCard["cc-name"] = "Foo Bar";
+  do_check_eq(profileStorage.creditCards.mergeToStorage(anotherCreditCard).length, 2);
+  do_check_eq(profileStorage.creditCards.getAll()[0]["cc-name"], "Foo Bar");
+  do_check_eq(profileStorage.creditCards.getAll()[0]["cc-exp"], "2000-01");
+  do_check_eq(profileStorage.creditCards.getAll()[1]["cc-name"], "Foo Bar");
+  do_check_eq(profileStorage.creditCards.getAll()[1]["cc-exp"], "2000-01");
+});