Bug 1402963 - Part 2: Merge the credit card record into existing data. r=lchang draft
authorsteveck-chung <schung@mozilla.com>
Wed, 25 Oct 2017 17:46:56 +0800
changeset 693983 14d71984b75e814fdef13261f1879c02a20f1c19
parent 693946 14fe87b077a88bd6aee3445ff6c5dc70a5e92a32
child 694013 1476dd08aab63bcfc215b5c297858a52cb9e67b6
child 694806 e2272a54d5b3b337c6b40a20330b02f54692b73e
child 695331 1dd0307d64dcaeef4cdfb5a362fe0048e1ce9f80
push id88006
push userbmo:schung@mozilla.com
push dateTue, 07 Nov 2017 07:49:35 +0000
reviewerslchang
bugs1402963
milestone58.0a1
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
@@ -1582,16 +1582,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";
@@ -272,19 +272,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");
+});