Repository: parse-community/Parse-SDK-JS
Pull request #750 (2019-03-18 19:04:08)

Support for Anonymous Users
Also fixed issue with sessionToken not being passed when saving users.

codecov[bot]: # [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/750?src=pr&el=h1) Report
> Merging [#750](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/750?src=pr&el=desc) into [master](https://codecov.io/gh/parse-community/Parse-SDK-JS/commit/8625ae2b17202b2a3163e2ef3c75f674e0be3f7d?src=pr&el=desc) will **increase** coverage by `0.92%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/750/graphs/tree.svg?width=650&token=8nCENM0QHj&height=150&src=pr)](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/750?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #750      +/-   ##
==========================================
+ Coverage   89.04%   89.97%   +0.92%     
==========================================
  Files          53       54       +1     
  Lines        4648     4676      +28     
  Branches     1072     1076       +4     
==========================================
+ Hits         4139     4207      +68     
+ Misses        509      469      -40
```


| [Impacted Files](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/750?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/Parse.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/750/diff?src=pr&el=tree#diff-c3JjL1BhcnNlLmpz) | `85.88% <100%> (+0.16%)` | :arrow_up: |
| [src/ParseUser.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/750/diff?src=pr&el=tree#diff-c3JjL1BhcnNlVXNlci5qcw==) | `79.7% <100%> (+10.25%)` | :arrow_up: |
| [src/ParseObject.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/750/diff?src=pr&el=tree#diff-c3JjL1BhcnNlT2JqZWN0Lmpz) | `89.87% <100%> (+0.13%)` | :arrow_up: |
| [src/AnonymousUtils.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/750/diff?src=pr&el=tree#diff-c3JjL0Fub255bW91c1V0aWxzLmpz) | `100% <100%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/750?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/750?src=pr&el=footer). Last update [8625ae2...d91f01f](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/750?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

acinader: super nice.

Patch:
diff --git a/src/AnonymousUtils.js b/src/AnonymousUtils.js
new file mode 100644
index 0000000..801d25b
--- /dev/null
+++ b/src/AnonymousUtils.js
@@ -0,0 +1,113 @@
+/**
+ * Copyright (c) 2015-present, Parse, LLC.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ *
+ * @flow-weak
+ */
+import ParseUser from './ParseUser';
+const uuidv4 = require('uuid/v4');
+
+let registered = false;
+
+/**
+ * Provides utility functions for working with Anonymously logged-in users. <br />
+ * Anonymous users have some unique characteristics:
+ * <ul>
+ *  <li>Anonymous users don't need a user name or password.</li>
+ *  <ul>
+ *    <li>Once logged out, an anonymous user cannot be recovered.</li>
+ *  </ul>
+ *  <li>signUp converts an anonymous user to a standard user with the given username and password.</li>
+ *  <ul>
+ *    <li>Data associated with the anonymous user is retained.</li>
+ *  </ul>
+ *  <li>logIn switches users without converting the anonymous user.</li>
+ *  <ul>
+ *    <li>Data associated with the anonymous user will be lost.</li>
+ *  </ul>
+ *  <li>Service logIn (e.g. Facebook, Twitter) will attempt to convert
+ *  the anonymous user into a standard user by linking it to the service.</li>
+ *  <ul>
+ *    <li>If a user already exists that is linked to the service, it will instead switch to the existing user.</li>
+ *  </ul>
+ *  <li>Service linking (e.g. Facebook, Twitter) will convert the anonymous user
+ *  into a standard user by linking it to the service.</li>
+ * </ul>
+ * @class Parse.AnonymousUtils
+ * @static
+ */
+const AnonymousUtils = {
+  /**
+   * Gets whether the user has their account linked to anonymous user.
+   *
+   * @method isLinked
+   * @name Parse.AnonymousUtils.isLinked
+   * @param {Parse.User} user User to check for.
+   *     The user must be logged in on this device.
+   * @return {Boolean} <code>true</code> if the user has their account
+   *     linked to an anonymous user.
+   * @static
+   */
+  isLinked(user: ParseUser) {
+    const provider = this._getAuthProvider();
+    return user._isLinked(provider.getAuthType());
+  },
+
+  /**
+   * Logs in a user Anonymously.
+   *
+   * @method logIn
+   * @name Parse.AnonymousUtils.logIn
+   * @returns {Promise}
+   * @static
+   */
+  logIn() {
+    const provider = this._getAuthProvider();
+    return ParseUser._logInWith(provider.getAuthType(), provider.getAuthData());
+  },
+
+  /**
+   * Links Anonymous User to an existing PFUser.
+   *
+   * @method link
+   * @name Parse.AnonymousUtils.link
+   * @param {Parse.User} user User to link. This must be the current user.
+   * @returns {Promise}
+   * @static
+   */
+  link(user: ParseUser) {
+    const provider = this._getAuthProvider();
+    return user._linkWith(provider.getAuthType(), provider.getAuthData());
+  },
+
+  _getAuthProvider() {
+    const provider = {
+      restoreAuthentication() {
+        return true;
+      },
+
+      getAuthType() {
+        return 'anonymous';
+      },
+
+      getAuthData() {
+        return {
+          authData: {
+            id: uuidv4(),
+          },
+        };
+      },
+    };
+    if (!registered) {
+      ParseUser._registerAuthenticationProvider(provider);
+      registered = true;
+    }
+    return provider;
+  }
+};
+
+export default AnonymousUtils;
diff --git a/src/Parse.js b/src/Parse.js
index c852f02..c38ef2a 100644
--- a/src/Parse.js
+++ b/src/Parse.js
@@ -173,6 +173,7 @@ Object.defineProperty(Parse, 'liveQueryServerURL', {
 
 Parse.ACL = require('./ParseACL').default;
 Parse.Analytics = require('./Analytics');
+Parse.AnonymousUtils = require('./AnonymousUtils').default;
 Parse.Cloud = require('./Cloud');
 Parse.CoreManager = require('./CoreManager');
 Parse.Config = require('./ParseConfig').default;
diff --git a/src/ParseObject.js b/src/ParseObject.js
index 913cd1f..678f645 100644
--- a/src/ParseObject.js
+++ b/src/ParseObject.js
@@ -1157,7 +1157,10 @@ class ParseObject {
     if (options.hasOwnProperty('sessionToken') && typeof options.sessionToken === 'string') {
       saveOptions.sessionToken = options.sessionToken;
     }
-
+    // Pass sessionToken if saving currentUser
+    if (typeof this.getSessionToken === 'function' && this.getSessionToken()) {
+      saveOptions.sessionToken = this.getSessionToken();
+    }
     const controller = CoreManager.getObjectController();
     const unsaved = unsavedChildren(this);
     return controller.save(unsaved, saveOptions).then(() => {
diff --git a/src/ParseUser.js b/src/ParseUser.js
index 65fe779..f3b27e2 100644
--- a/src/ParseUser.js
+++ b/src/ParseUser.js
@@ -9,6 +9,7 @@
  * @flow
  */
 
+import AnonymousUtils from './AnonymousUtils';
 import CoreManager from './CoreManager';
 import isRevocableSession from './isRevocableSession';
 import ParseError from './ParseError';
@@ -119,7 +120,6 @@ class ParseUser extends ParseObject {
   /**
    * Synchronizes auth data for a provider (e.g. puts the access token in the
    * right place to be used by the Facebook SDK).
-
    */
   _synchronizeAuthData(provider: string) {
     if (!this.isCurrent() || !provider) {
@@ -814,10 +814,15 @@ const DefaultController = {
   },
 
   setCurrentUser(user) {
+    const currentUser = this.currentUser();
+    let promise = Promise.resolve();
+    if (currentUser && !user.equals(currentUser) && AnonymousUtils.isLinked(currentUser)) {
+      promise = currentUser.destroy({ sessionToken: currentUser.getSessionToken() })
+    }
     currentUserCache = user;
     user._cleanupAuthData();
     user._synchronizeAllAuthData();
-    return DefaultController.updateUserOnDisk(user);
+    return promise.then(() => DefaultController.updateUserOnDisk(user));
   },
 
   currentUser(): ?ParseUser {
@@ -986,9 +991,14 @@ const DefaultController = {
       let promise = Storage.removeItemAsync(path);
       const RESTController = CoreManager.getRESTController();
       if (currentUser !== null) {
+        const isAnonymous = AnonymousUtils.isLinked(currentUser);
         const currentSession = currentUser.getSessionToken();
         if (currentSession && isRevocableSession(currentSession)) {
           promise = promise.then(() => {
+            if (isAnonymous) {
+              return currentUser.destroy({ sessionToken: currentSession });
+            }
+          }).then(() => {
             return RESTController.request(
               'POST', 'logout', {}, { sessionToken: currentSession }
             );


Test patch:
diff --git a/integration/test/ParseUserTest.js b/integration/test/ParseUserTest.js
index f7aafe4..1e4c7b5 100644
--- a/integration/test/ParseUserTest.js
+++ b/integration/test/ParseUserTest.js
@@ -439,4 +439,74 @@ describe('Parse User', () => {
       done();
     });
   });
+
+  it('can save anonymous user', async () => {
+    Parse.User.enableUnsafeCurrentUser();
+
+    const user = await Parse.AnonymousUtils.logIn();
+    user.set('field', 'hello');
+    await user.save();
+
+    const query = new Parse.Query(Parse.User);
+    const result = await query.get(user.id);
+    expect(result.get('field')).toBe('hello');
+  });
+
+  it('can not recover anonymous user if logged out', async () => {
+    Parse.User.enableUnsafeCurrentUser();
+
+    const user = await Parse.AnonymousUtils.logIn();
+    user.set('field', 'hello');
+    await user.save();
+
+    await Parse.User.logOut();
+
+    const query = new Parse.Query(Parse.User);
+    try {
+      await query.get(user.id);
+    } catch (error) {
+      expect(error.message).toBe('Object not found.');
+    }
+  });
+
+  it('can signUp anonymous user and retain data', async () => {
+    Parse.User.enableUnsafeCurrentUser();
+
+    const user = await Parse.AnonymousUtils.logIn();
+    user.set('field', 'hello world');
+    await user.save();
+
+    expect(user.get('authData').anonymous).toBeDefined();
+
+    user.setUsername('foo');
+    user.setPassword('baz');
+
+    await user.signUp();
+
+    const query = new Parse.Query(Parse.User);
+    const result = await query.get(user.id);
+    expect(result.get('username')).toBe('foo');
+    expect(result.get('authData')).toBeUndefined();
+    expect(result.get('field')).toBe('hello world');
+    expect(user.get('authData').anonymous).toBeUndefined();
+  });
+
+  it('can logIn user without converting anonymous user', async () => {
+    Parse.User.enableUnsafeCurrentUser();
+
+    await Parse.User.signUp('foobaz', '1234');
+
+    const user = await Parse.AnonymousUtils.logIn();
+    user.set('field', 'hello world');
+    await user.save();
+
+    await Parse.User.logIn('foobaz', '1234');
+
+    const query = new Parse.Query(Parse.User);
+    try {
+      await query.get(user.id);
+    } catch (error) {
+      expect(error.message).toBe('Object not found.');
+    }
+  });
 });
diff --git a/src/__tests__/AnonymousUtils-test.js b/src/__tests__/AnonymousUtils-test.js
new file mode 100644
index 0000000..f070555
--- /dev/null
+++ b/src/__tests__/AnonymousUtils-test.js
@@ -0,0 +1,88 @@
+/**
+ * Copyright (c) 2015-present, Parse, LLC.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ */
+
+jest.dontMock('../AnonymousUtils');
+
+class MockUser {
+  constructor () {
+    this.className = '_User';
+    this.attributes = {};
+  }
+  _isLinked() {}
+  _linkWith() {}
+  static _registerAuthenticationProvider() {}
+  static _logInWith() {}
+}
+
+jest.setMock('../ParseUser', MockUser);
+
+const mockProvider = {
+  restoreAuthentication() {
+    return true;
+  },
+
+  getAuthType() {
+    return 'anonymous';
+  },
+
+  getAuthData() {
+    return {
+      authData: {
+        id: '1234',
+      },
+    };
+  },
+};
+
+const AnonymousUtils = require('../AnonymousUtils').default;
+
+describe('AnonymousUtils', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+    jest.spyOn(
+      AnonymousUtils,
+      '_getAuthProvider'
+    )
+      .mockImplementation(() => mockProvider);
+  });
+
+  it('can register provider', () => {
+    AnonymousUtils._getAuthProvider.mockRestore();
+    jest.spyOn(MockUser, '_registerAuthenticationProvider');
+    AnonymousUtils._getAuthProvider();
+    AnonymousUtils._getAuthProvider();
+    expect(MockUser._registerAuthenticationProvider).toHaveBeenCalledTimes(1);
+  });
+
+  it('can check user isLinked', () => {
+    const user = new MockUser();
+    jest.spyOn(user, '_isLinked');
+    AnonymousUtils.isLinked(user);
+    expect(user._isLinked).toHaveBeenCalledTimes(1);
+    expect(user._isLinked).toHaveBeenCalledWith('anonymous');
+    expect(AnonymousUtils._getAuthProvider).toHaveBeenCalledTimes(1);
+  });
+
+  it('can link user', () => {
+    const user = new MockUser();
+    jest.spyOn(user, '_linkWith');
+    AnonymousUtils.link(user);
+    expect(user._linkWith).toHaveBeenCalledTimes(1);
+    expect(user._linkWith).toHaveBeenCalledWith('anonymous', mockProvider.getAuthData());
+    expect(AnonymousUtils._getAuthProvider).toHaveBeenCalledTimes(1);
+  });
+
+  it('can login user', () => {
+    jest.spyOn(MockUser, '_logInWith');
+    AnonymousUtils.logIn();
+    expect(MockUser._logInWith).toHaveBeenCalledTimes(1);
+    expect(MockUser._logInWith).toHaveBeenCalledWith('anonymous', mockProvider.getAuthData());
+    expect(AnonymousUtils._getAuthProvider).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/src/__tests__/ParseUser-test.js b/src/__tests__/ParseUser-test.js
index ced1ea1..4bfc5f3 100644
--- a/src/__tests__/ParseUser-test.js
+++ b/src/__tests__/ParseUser-test.js
@@ -7,6 +7,7 @@
  * of patent rights can be found in the PATENTS file in the same directory.
  */
 
+jest.dontMock('../AnonymousUtils');
 jest.dontMock('../CoreManager');
 jest.dontMock('../decode');
 jest.dontMock('../encode');
@@ -34,10 +35,15 @@ const ParseObject = require('../ParseObject').default;
 const ParseUser = require('../ParseUser').default;
 const Storage = require('../Storage');
 const ParseError = require('../ParseError').default;
+const AnonymousUtils = require('../AnonymousUtils').default;
 
 CoreManager.set('APPLICATION_ID', 'A');
 CoreManager.set('JAVASCRIPT_KEY', 'B');
 
+function flushPromises() {
+  return new Promise(resolve => setImmediate(resolve));
+}
+
 describe('ParseUser', () => {
   beforeEach(() => {
     ParseObject.enableSingleInstance();
@@ -714,6 +720,113 @@ describe('ParseUser', () => {
     });
   });
 
+  it('can sync anonymous user with current user', async () => {
+    const provider = AnonymousUtils._getAuthProvider();
+    jest.spyOn(provider, 'restoreAuthentication');
+
+    const object = new ParseUser();
+    object.set('authData', provider.getAuthData());
+
+    jest.spyOn(object, 'isCurrent')
+      .mockImplementationOnce(() => true);
+
+    const spy = jest.spyOn(ParseUser, 'currentAsync')
+      .mockImplementationOnce(() => Promise.resolve(object));
+
+    ParseUser._registerAuthenticationProvider(provider);
+
+    await flushPromises();
+
+    expect(ParseUser.currentAsync).toHaveBeenCalledTimes(1);
+    expect(provider.restoreAuthentication).toHaveBeenCalledTimes(1);
+    spy.mockRestore();
+  });
+
+  it('can pass sessionToken on save', async () => {
+    ParseUser.enableUnsafeCurrentUser();
+    ParseUser._clearCache();
+    CoreManager.setRESTController({
+      request() {
+        return Promise.resolve({
+          objectId: 'uid5',
+          sessionToken: 'r:123abc',
+          authData: {
+            anonymous: {
+              id: 'anonymousId',
+            }
+          }
+        }, 200);
+      },
+      ajax() {}
+    });
+    const user = await AnonymousUtils.logIn();
+    user.set('field', 'hello');
+    jest.spyOn(user, 'getSessionToken');
+
+    await user.save();
+    expect(user.getSessionToken).toHaveBeenCalledTimes(2);
+  });
+
+  it('can destroy anonymous user on logout', async () => {
+    ParseUser.enableUnsafeCurrentUser();
+    ParseUser._clearCache();
+    CoreManager.setRESTController({
+      request() {
+        return Promise.resolve({
+          objectId: 'uid5',
+          sessionToken: 'r:123abc',
+          authData: {
+            anonymous: {
+              id: 'anonymousId',
+            }
+          }
+        }, 200);
+      },
+      ajax() {}
+    });
+    const user = await AnonymousUtils.logIn();
+    jest.spyOn(user, 'destroy');
+    ParseUser._setCurrentUserCache(user);
+
+    await ParseUser.logOut();
+    expect(user.destroy).toHaveBeenCalledTimes(1);
+  });
+
+  it('can destroy anonymous user when login new user', async () => {
+    ParseUser.enableUnsafeCurrentUser();
+    ParseUser._clearCache();
+    CoreManager.setRESTController({
+      request() {
+        return Promise.resolve({
+          objectId: 'uid5',
+          sessionToken: 'r:123abc',
+          authData: {
+            anonymous: {
+              id: 'anonymousId',
+            }
+          }
+        }, 200);
+      },
+      ajax() {}
+    });
+    const user = await AnonymousUtils.logIn();
+    jest.spyOn(user, 'destroy');
+    ParseUser._setCurrentUserCache(user);
+
+    CoreManager.setRESTController({
+      request() {
+        return Promise.resolve({
+          objectId: 'uid2',
+          username: 'username',
+          sessionToken: '123abc'
+        }, 200);
+      },
+      ajax() {}
+    });
+    await ParseUser.logIn('username', 'password')
+    expect(user.destroy).toHaveBeenCalledTimes(1);
+  });
+
   it('strip anonymity when we set username', () => {
     const user = new ParseUser();
     const authData = {