Repository: parse-community/Parse-SDK-JS
Pull request #555 (2018-03-10 16:43:40)

Add Jobs Functions to Cloud
Closes: https://github.com/parse-community/Parse-SDK-JS/issues/486


Patch:
diff --git a/integration/cloud/main.js b/integration/cloud/main.js
new file mode 100644
index 0000000..87e79fc
--- /dev/null
+++ b/integration/cloud/main.js
@@ -0,0 +1,25 @@
+Parse.Cloud.define("bar", function(request, response) {
+  if (request.params.key2 === "value1") {
+    response.success('Foo');
+  } else {
+    response.error("bad stuff happened");
+  }
+});
+
+Parse.Cloud.job('CloudJob1', function(request, response) {
+  response.success({
+    status: 'cloud job completed'
+  });
+});
+
+Parse.Cloud.job('CloudJob2', function(request, response) {
+  setTimeout(function() {
+    response.success({
+      status: 'cloud job completed'
+    })
+  }, 3000);
+});
+
+Parse.Cloud.job('CloudJobFailing', function(request, response) {
+  response.error('cloud job failed');
+});
\ No newline at end of file
diff --git a/integration/server.js b/integration/server.js
index b7be8f3..82a7520 100644
--- a/integration/server.js
+++ b/integration/server.js
@@ -8,7 +8,8 @@ var api = new ParseServer({
   databaseURI: 'mongodb://localhost:27017/integration',
   appId: 'integration',
   masterKey: 'notsosecret',
-  serverURL: 'http://localhost:1337/parse' // Don't forget to change to https if needed
+  serverURL: 'http://localhost:1337/parse', // Don't forget to change to https if needed
+  cloud: `${__dirname}/cloud/main.js`
 });
 
 // Serve the Parse API on the /parse URL prefix
diff --git a/src/Cloud.js b/src/Cloud.js
index 74c91fa..249ca66 100644
--- a/src/Cloud.js
+++ b/src/Cloud.js
@@ -14,6 +14,7 @@ import decode from './decode';
 import encode from './encode';
 import ParseError from './ParseError';
 import ParsePromise from './ParsePromise';
+import ParseQuery from './ParseQuery';
 
 /**
  * Contains functions for calling and declaring
@@ -60,9 +61,69 @@ export function run(
     requestOptions.sessionToken = options.sessionToken;
   }
 
-  return (
-    CoreManager.getCloudController().run(name, data, requestOptions)._thenRunCallbacks(options)
-  );
+  return CoreManager.getCloudController().run(name, data, requestOptions)._thenRunCallbacks(options);
+}
+
+ /**
+  * Gets data for the current set of cloud jobs.
+  * @method getJobsData
+  * @name Parse.Cloud.getJobsData
+  * @param {Object} options A Backbone-style options object
+  * options.success, if set, should be a function to handle a successful
+  * call to a cloud function.  options.error should be a function that
+  * handles an error running the cloud function.  Both functions are
+  * optional.  Both functions take a single argument.
+  * @return {Parse.Promise} A promise that will be resolved with the result
+  * of the function.
+  */
+export function getJobsData(options: { [key: string]: mixed }): ParsePromise {
+  options = options || {};
+  const requestOptions = {
+    useMasterKey: true
+  };
+  return CoreManager.getCloudController().getJobsData(requestOptions)._thenRunCallbacks(options);
+}
+
+ /**
+  * Starts a given cloud job, which will process asynchronously.
+  * @method startJob
+  * @name Parse.Cloud.startJob
+  * @param {String} name The function name.
+  * @param {Object} data The parameters to send to the cloud function.
+  * @param {Object} options A Backbone-style options object
+  * options.success, if set, should be a function to handle a successful
+  * call to a cloud function.  options.error should be a function that
+  * handles an error running the cloud function.  Both functions are
+  * optional.  Both functions take a single argument.
+  * @return {Parse.Promise} A promise that will be resolved with the result
+  * of the function.
+  */
+export function startJob(
+  name: string,
+  data: mixed,
+  options: { [key: string]: mixed }
+): ParsePromise {
+  options = options || {};
+
+  if (typeof name !== 'string' || name.length === 0) {
+    throw new TypeError('Cloud job name must be a string.');
+  }
+  const requestOptions = {
+    useMasterKey: true
+  };
+  return CoreManager.getCloudController().startJob(name, data, requestOptions)._thenRunCallbacks(options);
+}
+
+ /**
+  * Gets job status by Id
+  * @method getJobStatus
+  * @name Parse.Cloud.getJobStatus
+  * @param {String} jobStatusId The Id of Job Status.
+  * @return {Parse.Object} Status of Job.
+  */
+export function getJobStatus(jobStatusId: string): ParsePromise {
+  var query = new ParseQuery('_JobStatus');
+  return query.get(jobStatusId, { useMasterKey: true });
 }
 
 var DefaultController = {
@@ -71,19 +132,11 @@ var DefaultController = {
 
     var payload = encode(data, true);
 
-    var requestOptions = {};
-    if (options.hasOwnProperty('useMasterKey')) {
-      requestOptions.useMasterKey = options.useMasterKey;
-    }
-    if (options.hasOwnProperty('sessionToken')) {
-      requestOptions.sessionToken = options.sessionToken;
-    }
-
     var request = RESTController.request(
       'POST',
       'functions/' + name,
       payload,
-      requestOptions
+      options
     );
 
     return request.then(function(res) {
@@ -97,7 +150,35 @@ var DefaultController = {
           'The server returned an invalid response.'
         )
       );
-    })._thenRunCallbacks(options);
+    });
+  },
+
+  getJobsData(options) {
+    var RESTController = CoreManager.getRESTController();
+
+    var request = RESTController.request(
+      'GET',
+      'cloud_code/jobs/data',
+      null,
+      options
+    );
+
+    return request;
+  },
+
+  startJob(name, data, options) {
+    var RESTController = CoreManager.getRESTController();
+
+    var payload = encode(data, true);
+
+    var request = RESTController.request(
+      'POST',
+      'jobs/' + name,
+      payload,
+      options,
+    );
+
+    return request;
   }
 };
 
diff --git a/src/CoreManager.js b/src/CoreManager.js
index 0d4a66b..8b38acd 100644
--- a/src/CoreManager.js
+++ b/src/CoreManager.js
@@ -30,6 +30,8 @@ type AnalyticsController = {
 };
 type CloudController = {
   run: (name: string, data: mixed, options: { [key: string]: mixed }) => ParsePromise;
+  getJobsData: (options: { [key: string]: mixed }) => ParsePromise;
+  startJob: (name: string, data: mixed, options: { [key: string]: mixed }) => ParsePromise;
 };
 type ConfigController = {
   current: () => ParsePromise;
@@ -197,7 +199,7 @@ module.exports = {
   },
 
   setCloudController(controller: CloudController) {
-    requireMethods('CloudController', ['run'], controller);
+    requireMethods('CloudController', ['run', 'getJobsData', 'startJob'], controller);
     config['CloudController'] = controller;
   },
 
diff --git a/src/RESTController.js b/src/RESTController.js
index 8dc6af5..c2bb5d4 100644
--- a/src/RESTController.js
+++ b/src/RESTController.js
@@ -100,6 +100,13 @@ const RESTController = {
           var response;
           try {
             response = JSON.parse(xhr.responseText);
+
+            if (typeof xhr.getResponseHeader === 'function') {
+              var jobStatusId = xhr.getResponseHeader('x-parse-job-status-id');
+              if (jobStatusId) {
+                response = jobStatusId;
+              }
+            }
           } catch (e) {
             promise.reject(e.toString());
           }


Test patch:
diff --git a/integration/test/ParseCloudTest.js b/integration/test/ParseCloudTest.js
new file mode 100644
index 0000000..8f5313b
--- /dev/null
+++ b/integration/test/ParseCloudTest.js
@@ -0,0 +1,126 @@
+'use strict';
+
+const assert = require('assert');
+const clear = require('./clear');
+const mocha = require('mocha');
+const Parse = require('../../node');
+
+describe('Parse Cloud', () => {
+  before((done) => {
+    Parse.initialize('integration', null, 'notsosecret');
+    Parse.CoreManager.set('SERVER_URL', 'http://localhost:1337/parse');
+    Parse.Storage._clear();
+    clear().then(() => { done() }, () => { done() });
+  });
+
+  it('run function', (done) => {
+    const params = { key1: 'value2', key2: 'value1' };
+    Parse.Cloud.run('bar', params).then((result) => {
+      assert.equal('Foo', result);
+      done();
+    });
+  });
+
+  it('run function with user', (done) => {
+    const params = { key1: 'value2', key2: 'value1' };
+    const user = new Parse.User();
+    user.setUsername('someuser');
+    user.setPassword('somepassword');
+    user.signUp().then(() => {
+      return Parse.Cloud.run('bar', params);
+    }).then((resp) => {
+      assert.equal('Foo', resp);
+      return user.destroy({ useMasterKey: true });
+    }).then(() => {
+      done();
+    });
+  });
+
+  it('run function failed', (done) => {
+    const params = { key1: 'value1', key2: 'value2' };
+    Parse.Cloud.run('bar', params).then(null).catch((error) => {
+      assert.equal(error.code, Parse.Error.SCRIPT_FAILED);
+      done();
+    });
+  });
+
+  it('run function name fail', (done) => {
+    const params = { key1: 'value1' };
+    Parse.Cloud.run('unknown_function', params).then(null).catch((error) => {
+      assert.equal(error.message, 'Invalid function: "unknown_function"');
+      done();
+    });
+  });
+
+  it('run function with geopoint params does not fail', (done) => {
+    const params = { key1: new Parse.GeoPoint(50, 50) };
+    Parse.Cloud.run('unknown_function', params).then(null).catch((error) => {
+      assert.equal(error.message, 'Invalid function: "unknown_function"');
+      done();
+    });
+  });
+
+  it('run function with object params fail', (done) => {
+    const object = new Parse.Object('TestClass');
+    const params = { key1: object };
+    try {
+      Parse.Cloud.run('bar', params);
+    } catch (e) {
+      assert.equal(e, 'Error: Parse Objects not allowed here');
+      done();
+    }
+  });
+
+  it('run job', (done) => {
+    const params = { startedBy: 'Monty Python' };
+    Parse.Cloud.startJob('CloudJob1', params).then((jobStatusId) => {
+      return Parse.Cloud.getJobStatus(jobStatusId);
+    }).then((jobStatus) => {
+      assert.equal(jobStatus.get('status'), 'succeeded');
+      assert.equal(jobStatus.get('params').startedBy, 'Monty Python');
+      done();
+    });
+  });
+
+  it('run long job', (done) => {
+    Parse.Cloud.startJob('CloudJob2').then((jobStatusId) => {
+      return Parse.Cloud.getJobStatus(jobStatusId);
+    }).then((jobStatus) => {
+      assert.equal(jobStatus.get('status'), 'running');
+      done();
+    });
+  });
+
+  it('run bad job', (done) => {
+    Parse.Cloud.startJob('bad_job').then(null).catch((error) => {
+      assert.equal(error.code, Parse.Error.SCRIPT_FAILED);
+      assert.equal(error.message, 'Invalid job.');
+      done();
+    });
+  });
+
+  it('run failing job', (done) => {
+    Parse.Cloud.startJob('CloudJobFailing').then((jobStatusId) => {
+      return Parse.Cloud.getJobStatus(jobStatusId);
+    }).then((jobStatus) => {
+      assert.equal(jobStatus.get('status'), 'failed');
+      assert.equal(jobStatus.get('message'), 'cloud job failed');
+      done();
+    });
+  });
+
+  it('get jobs data', (done) => {
+    Parse.Cloud.getJobsData().then((result) => {
+      assert.equal(result.in_use.length, 0);
+      assert.equal(result.jobs.length, 3);
+      done();
+    });
+  });
+
+  it('invalid job status id', (done) => {
+    Parse.Cloud.getJobStatus('not-a-real-id').then(null).catch((error) => {
+      assert.equal(error.message, 'Object not found.');
+      done();
+    });
+  });
+});
diff --git a/integration/test/ParseQueryAggregateTest.js b/integration/test/ParseQueryAggregateTest.js
index 7c55d06..d31d1d7 100644
--- a/integration/test/ParseQueryAggregateTest.js
+++ b/integration/test/ParseQueryAggregateTest.js
@@ -63,7 +63,7 @@ describe('Parse Aggregate Query', () => {
       assert.equal(results[0], 10);
       assert.equal(results[1], 20);
       done();
-    }).catch(done.fail);
+    });
   });
 
   it('distinct equalTo query', (done) => {
diff --git a/src/__tests__/Cloud-test.js b/src/__tests__/Cloud-test.js
index 94ab1ca..597bb5c 100644
--- a/src/__tests__/Cloud-test.js
+++ b/src/__tests__/Cloud-test.js
@@ -22,13 +22,21 @@ var defaultController = CoreManager.getCloudController();
 describe('Cloud', () => {
   beforeEach(() => {
     var run = jest.genMockFunction();
+    var getJobsData = jest.genMockFunction();
+    var startJob = jest.genMockFunction();
     run.mockReturnValue(ParsePromise.as({
       result: {}
     }));
-    CoreManager.setCloudController({ run: run });
+    getJobsData.mockReturnValue(ParsePromise.as({
+      result: {}
+    }));
+    startJob.mockReturnValue(ParsePromise.as({
+      result: {}
+    }));
+    CoreManager.setCloudController({ run, getJobsData, startJob });
   });
 
-  it('throws with an invalid function name', () => {
+  it('run throws with an invalid function name', () => {
     expect(Cloud.run)
       .toThrow('Cloud function name must be a string.');
 
@@ -39,14 +47,14 @@ describe('Cloud', () => {
       .toThrow('Cloud function name must be a string.');
   });
 
-  it('passes function name and data along', () => {
+  it('run passes function name and data along', () => {
     Cloud.run('myfunction', {});
 
     expect(CoreManager.getCloudController().run.mock.calls[0])
       .toEqual(['myfunction', {}, {}]);
   });
 
-  it('passes options', () => {
+  it('run passes options', () => {
     Cloud.run('myfunction', {}, { useMasterKey: false });
 
     expect(CoreManager.getCloudController().run.mock.calls[0])
@@ -66,7 +74,44 @@ describe('Cloud', () => {
 
     expect(CoreManager.getCloudController().run.mock.calls[3])
       .toEqual(['myfunction', {}, { useMasterKey: true, sessionToken: 'asdf1234' }]);
-  })
+  });
+
+  it('startJob throws with an invalid job name', () => {
+    expect(Cloud.startJob)
+      .toThrow('Cloud job name must be a string.');
+
+    expect(Cloud.startJob.bind(null, ''))
+      .toThrow('Cloud job name must be a string.');
+
+    expect(Cloud.startJob.bind(null, {}))
+      .toThrow('Cloud job name must be a string.');
+  });
+
+  it('startJob passes function name and data along', () => {
+    Cloud.startJob('myJob', {});
+
+    expect(CoreManager.getCloudController().startJob.mock.calls[0])
+      .toEqual(['myJob', {}, { useMasterKey: true }]);
+  });
+
+  it('startJob passes options', () => {
+    Cloud.startJob('myJob', {}, { useMasterKey: true });
+
+    expect(CoreManager.getCloudController().startJob.mock.calls[0])
+      .toEqual(['myJob', {}, { useMasterKey: true }]);
+  });
+
+  it('getJobsData passes options', () => {
+    Cloud.getJobsData();
+
+    expect(CoreManager.getCloudController().getJobsData.mock.calls[0])
+      .toEqual([{ useMasterKey: true }]);
+
+    Cloud.getJobsData({ useMasterKey: true });
+
+    expect(CoreManager.getCloudController().getJobsData.mock.calls[0])
+      .toEqual([{ useMasterKey: true }]);
+  });
 });
 
 describe('CloudController', () => {
@@ -81,7 +126,7 @@ describe('CloudController', () => {
     CoreManager.setRESTController({ request: request, ajax: ajax });
   });
 
-  it('passes encoded requests', () => {
+  it('run passes encoded requests', () => {
     Cloud.run('myfunction', { value: 12, when: new Date(Date.UTC(2015,0,1)) });
 
     expect(CoreManager.getRESTController().request.mock.calls[0])
@@ -90,7 +135,7 @@ describe('CloudController', () => {
       }, { }]);
   });
 
-  it('passes options', () => {
+  it('run passes options', () => {
     Cloud.run('myfunction', { value: 12 }, { useMasterKey: true });
 
     expect(CoreManager.getRESTController().request.mock.calls[0])
@@ -105,4 +150,49 @@ describe('CloudController', () => {
         value: 12
       }, { sessionToken: 'asdf1234' }]);
   });
+
+  it('run invalid response', () => {
+    var request = jest.genMockFunction();
+    request.mockReturnValue(ParsePromise.as({
+      success: false
+    }));
+    var ajax = jest.genMockFunction();
+    CoreManager.setRESTController({ request: request, ajax: ajax });
+
+    Cloud.run('myfunction').then(null).catch(() => {
+      done();
+    });
+  });
+
+  it('startJob passes encoded requests', () => {
+    Cloud.startJob('myJob', { value: 12, when: new Date(Date.UTC(2015,0,1)) });
+
+    expect(CoreManager.getRESTController().request.mock.calls[0])
+      .toEqual(['POST', 'jobs/myJob', {
+        value: 12, when: { __type: 'Date', iso: '2015-01-01T00:00:00.000Z'}
+      }, { useMasterKey: true }]);
+  });
+
+  it('startJob passes options', () => {
+    Cloud.startJob('myJob', { value: 12 }, { useMasterKey: true });
+
+    expect(CoreManager.getRESTController().request.mock.calls[0])
+      .toEqual(['POST', 'jobs/myJob', {
+        value: 12
+      }, { useMasterKey: true }]);
+  });
+
+  it('getJobsData passes no options', () => {
+    Cloud.getJobsData();
+
+    expect(CoreManager.getRESTController().request.mock.calls[0])
+      .toEqual(['GET', 'cloud_code/jobs/data', null, { useMasterKey: true }]);
+  });
+
+  it('getJobsData passes options', () => {
+    Cloud.getJobsData({ useMasterKey: true });
+
+    expect(CoreManager.getRESTController().request.mock.calls[0])
+      .toEqual(['GET', 'cloud_code/jobs/data', null, { useMasterKey: true }]);
+  });
 });
diff --git a/src/__tests__/CoreManager-test.js b/src/__tests__/CoreManager-test.js
index 30775c4..ef60ed4 100644
--- a/src/__tests__/CoreManager-test.js
+++ b/src/__tests__/CoreManager-test.js
@@ -57,13 +57,19 @@ describe('CoreManager', () => {
     );
 
     expect(CoreManager.setCloudController.bind(null, {
-      run: function() {}
+      run: function() {},
+      getJobsData: function() {},
+      startJob: function() {},
+      getJobStatus: function() {}
     })).not.toThrow();
   });
 
   it('can set and get CloudController', () => {
     var controller = {
-      run: function() {}
+      run: function() {},
+      getJobsData: function() {},
+      startJob: function() {},
+      getJobStatus: function() {}
     };
 
     CoreManager.setCloudController(controller);
diff --git a/src/__tests__/RESTController-test.js b/src/__tests__/RESTController-test.js
index 20965e0..34a27f3 100644
--- a/src/__tests__/RESTController-test.js
+++ b/src/__tests__/RESTController-test.js
@@ -206,6 +206,48 @@ describe('RESTController', () => {
       });
   });
 
+  it('handles x-parse-job-status-id header', (done) => {
+    var XHR = function() { };
+    XHR.prototype = {
+      open: function() { },
+      setRequestHeader: function() { },
+      getResponseHeader: function(name) { return 1234; },
+      send: function() {
+        this.status = 200;
+        this.responseText = '{}';
+        this.readyState = 4;
+        this.onreadystatechange();
+      }
+    };
+    RESTController._setXHR(XHR);
+    RESTController.request('GET', 'classes/MyObject', {}, {})
+      .then((response) => {
+        expect(response).toBe(1234);
+        done();
+      });
+  });
+
+  it('handles invalid header', (done) => {
+    var XHR = function() { };
+    XHR.prototype = {
+      open: function() { },
+      setRequestHeader: function() { },
+      getResponseHeader: function(name) { return null; },
+      send: function() {
+        this.status = 200;
+        this.responseText = '{"result":"hello"}';
+        this.readyState = 4;
+        this.onreadystatechange();
+      }
+    };
+    RESTController._setXHR(XHR);
+    RESTController.request('GET', 'classes/MyObject', {}, {})
+      .then((response) => {
+        expect(response.result).toBe('hello');
+        done();
+      });
+  });
+
   it('attaches the session token of the current user', () => {
     CoreManager.setUserController({
       currentUserAsync() {