Repository: parse-community/Parse-Swift (swift)

Issue:
Verification Emails are triggered every time User.current is saved
### New Issue Checklist
<!--
    Check every following box [x] before submitting your issue.
    Click the "Preview" tab for better readability.
    Thanks for contributing to Parse Platform!
-->

- [x] I am not disclosing a [vulnerability](https://github.com/parse-community/Parse-Swift/security/policy).
- [x] I am not just asking a [question](https://github.com/parse-community/.github/blob/main/SUPPORT.md).
- [x] I have searched through [existing issues](https://github.com/parse-community/Parse-Swift/issues?q=is%3Aissue).
- [x] I can reproduce the issue with the latest versions of [Parse Server](https://github.com/parse-community/parse-server/releases) and the [Parse Swift SDK](https://github.com/parse-community/Parse-Swift/releases). <!-- We don't investigate issues for outdated releases. -->

### Issue Description
<!-- What is the specific issue? -->

Parse Swift triggers email verification whenever `User.current?.save` is called.

### Steps to reproduce
<!-- How can someone else reproduce the issue? -->

1. Turn `verifyUserEmails` on.
2. Update any field on `User.current`
3. Check emails

### Actual Outcome
<!-- What outcome did you get? -->

Emails are sent every time user is updated

### Expected Outcome
<!-- What outcome did you expect? -->

Emails to only send on signup, or email update, as per the SDK.

Specifically, the issue seems to be related to here o the server:

```js
RestWrite.prototype._validateEmail = function () {
 if (!this.data.email || this.data.email.__op === 'Delete') {
    return Promise.resolve();
  } 
```

For the JS SDK, `this.data.email` is undefined on any update. For Parse Swift calls, this.data.email is passed.


### Environment
<!-- Be specific with versions, don't use "latest" or semver ranges like "~x.y.z" or "^x.y.z". -->

Client
- Parse Swift SDK version: `1.9.10`
- Xcode version: `10.15.7`
- Operating system (iOS, macOS, watchOS, etc.): `iOS`
- Operating system version: `14.3`

Server
- Parse Server version: `Master (around 4.5.0`
- Operating system: `macOS`
- Local or remote host (AWS, Azure, Google Cloud, Heroku, Digital Ocean, etc): `local`

Database
- System (MongoDB or Postgres): `mongoDB`
- Database version: `4.4`
- Local or remote host (MongoDB Atlas, mLab, AWS, Azure, Google Cloud, etc): `Atlas`

### Logs
<!-- Include relevant logs here. -->

Pull request:
### New Pull Request Checklist
<!--
    Please check the following boxes [x] before submitting your issue.
    Click the "Preview" tab for better readability.
    Thanks for contributing to Parse Platform!
-->

- [x] I am not disclosing a [vulnerability](https://github.com/parse-community/Parse-Swift/security/policy).
- [x] I am creating this PR in reference to an [issue](https://github.com/parse-community/Parse-Swift/issues?q=is%3Aissue).

### Issue Description
<!-- Add a brief description of the issue this PR solves. -->

When a current user email isn't modified, it shouldn't be sent to the server or else it will trigger verify email.

Related issue: #239 

### Approach
<!-- Add a description of the approach in this PR. -->
Only encode email if it's modified.


### TODOs before merging
<!--
    Add TODOs that need to be completed before merging this PR.
    Delete TODOs that do not apply to this PR.
-->

- [x] Add tests
- [x] Add entry to changelog

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 580a63978..358c52e9f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@
 [Full Changelog](https://github.com/parse-community/Parse-Swift/compare/1.9.10...main)
 * _Contributing to this repo? Add info about your change here to be included in the next release_
 
+__Fixes__
+- ParseUser shouldn't send email if it hasn't been modified or else email verification is resent ([#241](https://github.com/parse-community/Parse-Swift/pull/241)), thanks to [Corey Baker](https://github.com/cbaker6).
+
 ### 1.9.10
 [Full Changelog](https://github.com/parse-community/Parse-Swift/compare/1.9.9...1.9.10)
 __Fixes__
diff --git a/Sources/ParseSwift/Objects/ParseUser.swift b/Sources/ParseSwift/Objects/ParseUser.swift
index cd157ee2d..42ed4f8f0 100644
--- a/Sources/ParseSwift/Objects/ParseUser.swift
+++ b/Sources/ParseSwift/Objects/ParseUser.swift
@@ -944,12 +944,18 @@ extension ParseUser {
     }
 
     private func updateCommand() -> API.Command<Self, Self> {
+        var mutableSelf = self
+        if let currentUser = Self.current,
+           currentUser.hasSameObjectId(as: mutableSelf) == true,
+           currentUser.email == mutableSelf.email {
+            mutableSelf.email = nil
+        }
         let mapper = { (data) -> Self in
             try ParseCoding.jsonDecoder().decode(UpdateResponse.self, from: data).apply(to: self)
         }
         return API.Command<Self, Self>(method: .PUT,
                                  path: endpoint,
-                                 body: self,
+                                 body: mutableSelf,
                                  mapper: mapper)
     }
 }
diff --git a/Sources/ParseSwift/Types/CloudViewModel.swift b/Sources/ParseSwift/Types/CloudViewModel.swift
index 5d2b97959..d04bb7b0b 100644
--- a/Sources/ParseSwift/Types/CloudViewModel.swift
+++ b/Sources/ParseSwift/Types/CloudViewModel.swift
@@ -29,7 +29,7 @@ open class CloudViewModel<T: ParseCloud>: CloudObservable {
     }
 
     /// Updates and notifies when there is an error retrieving the results.
-    open var error: ParseError? = nil {
+    open var error: ParseError? {
         willSet {
             if newValue != nil {
                 self.results = nil
diff --git a/Sources/ParseSwift/Types/QueryViewModel.swift b/Sources/ParseSwift/Types/QueryViewModel.swift
index 381e083e3..a3ba9cd1c 100644
--- a/Sources/ParseSwift/Types/QueryViewModel.swift
+++ b/Sources/ParseSwift/Types/QueryViewModel.swift
@@ -38,7 +38,7 @@ open class QueryViewModel<T: ParseObject>: QueryObservable {
     }
 
     /// Updates and notifies when there is an error retrieving the results.
-    open var error: ParseError? = nil {
+    open var error: ParseError? {
         willSet {
             if newValue != nil {
                 results.removeAll()


Test patch:
diff --git a/Tests/ParseSwiftTests/ParseUserTests.swift b/Tests/ParseSwiftTests/ParseUserTests.swift
index 9f45982d5..db7ba3d0e 100644
--- a/Tests/ParseSwiftTests/ParseUserTests.swift
+++ b/Tests/ParseSwiftTests/ParseUserTests.swift
@@ -61,6 +61,16 @@ class ParseUserTests: XCTestCase { // swiftlint:disable:this type_body_length
             self.email = "hello@parse.com"
             self.emailVerified = false
         }
+
+        func createUser() -> User {
+            var user = User()
+            user.objectId = objectId
+            user.ACL = ACL
+            user.customKey = customKey
+            user.username = username
+            user.email = email
+            return user
+        }
     }
 
     let loginUserName = "hello10"
@@ -454,17 +464,159 @@ class ParseUserTests: XCTestCase { // swiftlint:disable:this type_body_length
         XCTAssertNotNil(command.body)
     }
 
-    func testSaveAndUpdateCurrentUser() { // swiftlint:disable:this function_body_length
-        XCTAssertNil(User.current?.objectId)
-        testLogin()
+    func userSignUp() throws {
+        let loginResponse = LoginSignupResponse()
+
+        MockURLProtocol.mockRequests { _ in
+            do {
+                let encoded = try loginResponse.getEncoder().encode(loginResponse, skipKeys: .none)
+                return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0)
+            } catch {
+                return nil
+            }
+        }
+        _ = try loginResponse.createUser().signup()
         MockURLProtocol.removeAll()
+        guard let currentUser = User.current else {
+            XCTFail("Should have a current user after signup")
+            return
+        }
+        XCTAssertEqual(currentUser.objectId, loginResponse.objectId)
+        XCTAssertEqual(currentUser.username, loginResponse.username)
+        XCTAssertEqual(currentUser.email, loginResponse.email)
+        XCTAssertEqual(currentUser.ACL, loginResponse.ACL)
+        XCTAssertEqual(currentUser.customKey, loginResponse.customKey)
+    }
+
+    func testUpdateCommandUnmodifiedEmail() throws {
+        try userSignUp()
+        guard let user = User.current,
+              let objectId = user.objectId else {
+            XCTFail("Should have current user.")
+            return
+        }
+        XCTAssertNotNil(user.email)
+        let command = try user.saveCommand()
+        XCTAssertNotNil(command)
+        XCTAssertEqual(command.path.urlComponent, "/users/\(objectId)")
+        XCTAssertEqual(command.method, API.Method.PUT)
+        XCTAssertNil(command.params)
+        XCTAssertNotNil(command.body)
+        XCTAssertNil(command.body?.email)
+    }
+
+    func testUpdateCommandModifiedEmail() throws {
+        try userSignUp()
+        guard var user = User.current,
+              let objectId = user.objectId else {
+            XCTFail("Should have current user.")
+            return
+        }
+        let email = "peace@parse.com"
+        user.email = email
+        XCTAssertNotNil(user.email)
+        let command = try user.saveCommand()
+        XCTAssertNotNil(command)
+        XCTAssertEqual(command.path.urlComponent, "/users/\(objectId)")
+        XCTAssertEqual(command.method, API.Method.PUT)
+        XCTAssertNil(command.params)
+        XCTAssertNotNil(command.body)
+        XCTAssertEqual(command.body?.email, email)
+    }
+
+    func testUpdateCommandNotCurrentModifiedEmail() throws {
+        try userSignUp()
+        var user = User()
+        let objectId = "yarr"
+        user.objectId = objectId
+        let email = "peace@parse.com"
+        user.email = email
+        XCTAssertNotNil(user.email)
+        let command = try user.saveCommand()
+        XCTAssertNotNil(command)
+        XCTAssertEqual(command.path.urlComponent, "/users/\(objectId)")
+        XCTAssertEqual(command.method, API.Method.PUT)
+        XCTAssertNil(command.params)
+        XCTAssertNotNil(command.body)
+        XCTAssertEqual(command.body?.email, email)
+    }
+
+    func testSaveAndUpdateCurrentUser() throws { // swiftlint:disable:this function_body_length
+        XCTAssertNil(User.current?.objectId)
+        try userSignUp()
         XCTAssertNotNil(User.current?.objectId)
 
         guard let user = User.current else {
             XCTFail("Should unwrap")
             return
         }
+        XCTAssertNotNil(user.email)
+        var userOnServer = user
+        userOnServer.createdAt = User.current?.createdAt
+        userOnServer.updatedAt = User.current?.updatedAt?.addingTimeInterval(+300)
+
+        let encoded: Data!
+        do {
+            encoded = try userOnServer.getEncoder().encode(userOnServer, skipKeys: .none)
+            //Get dates in correct format from ParseDecoding strategy
+            userOnServer = try userOnServer.getDecoder().decode(User.self, from: encoded)
+        } catch {
+            XCTFail("Should encode/decode. Error \(error)")
+            return
+        }
+        MockURLProtocol.mockRequests { _ in
+            return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0)
+        }
+
+        do {
+            let saved = try user.save(options: [.useMasterKey])
+            XCTAssert(saved.hasSameObjectId(as: userOnServer))
+            XCTAssertEqual(saved.email, user.email)
+            guard let savedCreatedAt = saved.createdAt,
+                let savedUpdatedAt = saved.updatedAt else {
+                    XCTFail("Should unwrap dates")
+                    return
+            }
+            guard let originalCreatedAt = user.createdAt,
+                let originalUpdatedAt = user.updatedAt else {
+                    XCTFail("Should unwrap dates")
+                    return
+            }
+            XCTAssertEqual(savedCreatedAt, originalCreatedAt)
+            XCTAssertGreaterThan(savedUpdatedAt, originalUpdatedAt)
+            XCTAssertNil(saved.ACL)
+
+            //Should be updated in memory
+            XCTAssertEqual(User.current?.updatedAt, savedUpdatedAt)
+            XCTAssertEqual(User.current?.email, user.email)
 
+            #if !os(Linux) && !os(Android)
+            //Should be updated in Keychain
+            guard let keychainUser: CurrentUserContainer<BaseParseUser>
+                = try? KeychainStore.shared.get(valueFor: ParseStorage.Keys.currentUser) else {
+                    XCTFail("Should get object from Keychain")
+                return
+            }
+            XCTAssertEqual(keychainUser.currentUser?.updatedAt, savedUpdatedAt)
+            XCTAssertEqual(keychainUser.currentUser?.email, user.email)
+            #endif
+
+        } catch {
+            XCTFail(error.localizedDescription)
+        }
+    }
+
+    func testSaveAndUpdateCurrentUserModifiedEmail() throws { // swiftlint:disable:this function_body_length
+        XCTAssertNil(User.current?.objectId)
+        try userSignUp()
+        XCTAssertNotNil(User.current?.objectId)
+
+        guard var user = User.current else {
+            XCTFail("Should unwrap")
+            return
+        }
+        user.email = "pease@parse.com"
+        XCTAssertNotEqual(User.current?.email, user.email)
         var userOnServer = user
         userOnServer.createdAt = User.current?.createdAt
         userOnServer.updatedAt = User.current?.updatedAt?.addingTimeInterval(+300)
@@ -485,6 +637,7 @@ class ParseUserTests: XCTestCase { // swiftlint:disable:this type_body_length
         do {
             let saved = try user.save(options: [.useMasterKey])
             XCTAssert(saved.hasSameObjectId(as: userOnServer))
+            XCTAssertEqual(saved.email, user.email)
             guard let savedCreatedAt = saved.createdAt,
                 let savedUpdatedAt = saved.updatedAt else {
                     XCTFail("Should unwrap dates")
@@ -501,6 +654,7 @@ class ParseUserTests: XCTestCase { // swiftlint:disable:this type_body_length
 
             //Should be updated in memory
             XCTAssertEqual(User.current?.updatedAt, savedUpdatedAt)
+            XCTAssertEqual(User.current?.email, user.email)
 
             #if !os(Linux) && !os(Android)
             //Should be updated in Keychain
@@ -510,6 +664,7 @@ class ParseUserTests: XCTestCase { // swiftlint:disable:this type_body_length
                 return
             }
             XCTAssertEqual(keychainUser.currentUser?.updatedAt, savedUpdatedAt)
+            XCTAssertEqual(keychainUser.currentUser?.email, user.email)
             #endif
 
         } catch {
@@ -517,17 +672,90 @@ class ParseUserTests: XCTestCase { // swiftlint:disable:this type_body_length
         }
     }
 
-    func testSaveAsyncAndUpdateCurrentUser() { // swiftlint:disable:this function_body_length
+    func testSaveAsyncAndUpdateCurrentUser() throws { // swiftlint:disable:this function_body_length
         XCTAssertNil(User.current?.objectId)
-        testLogin()
-        MockURLProtocol.removeAll()
+        try userSignUp()
         XCTAssertNotNil(User.current?.objectId)
 
         guard let user = User.current else {
             XCTFail("Should unwrap")
             return
         }
+        XCTAssertNotNil(user.email)
+        var userOnServer = user
+        userOnServer.createdAt = User.current?.createdAt
+        userOnServer.updatedAt = User.current?.updatedAt?.addingTimeInterval(+300)
+
+        let encoded: Data!
+        do {
+            encoded = try userOnServer.getEncoder().encode(userOnServer, skipKeys: .none)
+            //Get dates in correct format from ParseDecoding strategy
+            userOnServer = try userOnServer.getDecoder().decode(User.self, from: encoded)
+        } catch {
+            XCTFail("Should encode/decode. Error \(error)")
+            return
+        }
+        MockURLProtocol.mockRequests { _ in
+            return MockURLResponse(data: encoded, statusCode: 200, delay: 0.0)
+        }
 
+        let expectation1 = XCTestExpectation(description: "Fetch user1")
+        user.save(options: [], callbackQueue: .global(qos: .background)) { result in
+
+            switch result {
+            case .success(let saved):
+                XCTAssert(saved.hasSameObjectId(as: userOnServer))
+                XCTAssertEqual(saved.email, user.email)
+                guard let savedCreatedAt = saved.createdAt,
+                    let savedUpdatedAt = saved.updatedAt else {
+                        XCTFail("Should unwrap dates")
+                    expectation1.fulfill()
+                        return
+                }
+                guard let originalCreatedAt = user.createdAt,
+                    let originalUpdatedAt = user.updatedAt else {
+                        XCTFail("Should unwrap dates")
+                    expectation1.fulfill()
+                        return
+                }
+                XCTAssertEqual(savedCreatedAt, originalCreatedAt)
+                XCTAssertGreaterThan(savedUpdatedAt, originalUpdatedAt)
+                XCTAssertNil(saved.ACL)
+
+                //Should be updated in memory
+                XCTAssertEqual(User.current?.updatedAt, savedUpdatedAt)
+                XCTAssertEqual(User.current?.email, user.email)
+
+                #if !os(Linux) && !os(Android)
+                //Should be updated in Keychain
+                guard let keychainUser: CurrentUserContainer<BaseParseUser>
+                    = try? KeychainStore.shared.get(valueFor: ParseStorage.Keys.currentUser) else {
+                        XCTFail("Should get object from Keychain")
+                    return
+                }
+                XCTAssertEqual(keychainUser.currentUser?.updatedAt, savedUpdatedAt)
+                XCTAssertEqual(keychainUser.currentUser?.email, user.email)
+                #endif
+
+            case .failure(let error):
+                XCTFail(error.localizedDescription)
+            }
+            expectation1.fulfill()
+        }
+        wait(for: [expectation1], timeout: 20.0)
+    }
+
+    func testSaveAsyncAndUpdateCurrentUserModifiedEmail() throws { // swiftlint:disable:this function_body_length
+        XCTAssertNil(User.current?.objectId)
+        try userSignUp()
+        XCTAssertNotNil(User.current?.objectId)
+
+        guard var user = User.current else {
+            XCTFail("Should unwrap")
+            return
+        }
+        user.email = "pease@parse.com"
+        XCTAssertNotEqual(User.current?.email, user.email)
         var userOnServer = user
         userOnServer.createdAt = User.current?.createdAt
         userOnServer.updatedAt = User.current?.updatedAt?.addingTimeInterval(+300)
@@ -549,10 +777,11 @@ class ParseUserTests: XCTestCase { // swiftlint:disable:this type_body_length
         user.save(options: [], callbackQueue: .global(qos: .background)) { result in
 
             switch result {
-            case .success(let fetched):
-                XCTAssert(fetched.hasSameObjectId(as: userOnServer))
-                guard let fetchedCreatedAt = fetched.createdAt,
-                    let fetchedUpdatedAt = fetched.updatedAt else {
+            case .success(let saved):
+                XCTAssert(saved.hasSameObjectId(as: userOnServer))
+                XCTAssertEqual(saved.email, user.email)
+                guard let savedCreatedAt = saved.createdAt,
+                    let savedUpdatedAt = saved.updatedAt else {
                         XCTFail("Should unwrap dates")
                     expectation1.fulfill()
                         return
@@ -563,12 +792,13 @@ class ParseUserTests: XCTestCase { // swiftlint:disable:this type_body_length
                     expectation1.fulfill()
                         return
                 }
-                XCTAssertEqual(fetchedCreatedAt, originalCreatedAt)
-                XCTAssertGreaterThan(fetchedUpdatedAt, originalUpdatedAt)
-                XCTAssertNil(fetched.ACL)
+                XCTAssertEqual(savedCreatedAt, originalCreatedAt)
+                XCTAssertGreaterThan(savedUpdatedAt, originalUpdatedAt)
+                XCTAssertNil(saved.ACL)
 
                 //Should be updated in memory
-                XCTAssertEqual(User.current?.updatedAt, fetchedUpdatedAt)
+                XCTAssertEqual(User.current?.updatedAt, savedUpdatedAt)
+                XCTAssertEqual(User.current?.email, user.email)
 
                 #if !os(Linux) && !os(Android)
                 //Should be updated in Keychain
@@ -577,7 +807,8 @@ class ParseUserTests: XCTestCase { // swiftlint:disable:this type_body_length
                         XCTFail("Should get object from Keychain")
                     return
                 }
-                XCTAssertEqual(keychainUser.currentUser?.updatedAt, fetchedUpdatedAt)
+                XCTAssertEqual(keychainUser.currentUser?.updatedAt, savedUpdatedAt)
+                XCTAssertEqual(keychainUser.currentUser?.email, user.email)
                 #endif
 
             case .failure(let error):